From 75d4b8088634da5a21e8c340df436e4a7eb16ed4 Mon Sep 17 00:00:00 2001 From: Chris Cappuccio Date: Fri, 18 Sep 2026 18:12:33 -0700 Subject: [PATCH 01/10] Keep device runtime status out of PostgreSQL --- internal/firmware/service.go | 2 - internal/poller/airmax_poll.go | 28 +----------- internal/poller/canonical_mac.go | 3 +- internal/poller/poller_core.go | 77 +++++++++++++++----------------- internal/poller/wave_poll.go | 15 ++----- 5 files changed, 42 insertions(+), 83 deletions(-) diff --git a/internal/firmware/service.go b/internal/firmware/service.go index f22c294..a55c83c 100644 --- a/internal/firmware/service.go +++ b/internal/firmware/service.go @@ -1093,7 +1093,6 @@ func (s *Service) UpgradeDevice(ctx context.Context, deviceID int64, firmwareFil result.Status = "success" result.Message = "upgrade initiated, device rebooting" dbExecIgnore(s.db, `UPDATE firmware_jobs SET status = 'success', completed_at = NOW() WHERE id = $1`, jobID) - dbExecIgnore(s.db, `UPDATE devices SET status = 'upgrading' WHERE id = $1`, deviceID) return result, nil } @@ -1999,7 +1998,6 @@ func (s *Service) RetryUpgradeWithCredentials(ctx context.Context, deviceIDs []i result.Status = "success" result.Message = "upgrade initiated, device rebooting" dbExecIgnore(s.db, `UPDATE firmware_jobs SET status = 'success', completed_at = NOW() WHERE id = $1`, jobID) - dbExecIgnore(s.db, `UPDATE devices SET status = 'upgrading' WHERE id = $1`, id) // Persist the successful credential only in encrypted form. storedPassword := password if s.secretStore != nil { diff --git a/internal/poller/airmax_poll.go b/internal/poller/airmax_poll.go index f8bb9bb..bc71a9f 100644 --- a/internal/poller/airmax_poll.go +++ b/internal/poller/airmax_poll.go @@ -108,32 +108,10 @@ func (p *Poller) pollDeviceAirMAX(job pollJob) pollResult { p.wsHub.BroadcastStatsUpdate(int(job.DeviceID), job.MAC, job.IP, map[string]any{"online": false, "status": status, "db_status": status, "status_reason": reason, "last_error": err.Error()}) } - // Persist status to DB when: - // 1) we just left "online" (online -> unknown/offline), - // 2) the device responded and should be "unknown" (offline -> unknown), OR - // 3) we just crossed the offline threshold (unknown -> offline). becameOffline := prevStatus != stats.StatusOffline && status == "offline" - shouldUpdate := leftOnline || becameOffline || (!unreachable && status == "unknown") - if shouldUpdate { - p.logDebug("AirMAX %s: updating DB to '%s' (leftOnline=%v, becameOffline=%v, unreachable=%v)", job.IP, status, leftOnline, becameOffline, unreachable) - // Only advance last_seen when the device actually responded (e.g. auth failure, TCP RST). - // For truly unreachable failures we intentionally do NOT advance last_seen. - var result sql.Result - var dbErr error - if unreachable { - result, dbErr = dbExecCtx(p.db, dbCtxForJob(job, "airmax_update_status_auth_fail_unreachable"), `UPDATE devices SET status = $1, status_reason = $3 WHERE id = $2`, status, job.DeviceID, reason) - } else { - result, dbErr = dbExecCtx(p.db, dbCtxForJob(job, "airmax_update_status_auth_fail"), `UPDATE devices SET status = $1, status_reason = $3, last_seen = NOW() WHERE id = $2`, status, job.DeviceID, reason) - } - if dbErr != nil { - log.Printf("WARN: AirMAX %s: DB update failed: %v", job.IP, dbErr) - } else if rows, _ := result.RowsAffected(); rows == 0 { - log.Printf("WARN: AirMAX %s: DB update affected 0 rows (device ID %d)", job.IP, job.DeviceID) - } - // Also update children (STAs) to same status + if leftOnline || becameOffline || (!unreachable && status == "unknown") { + // Parent/child runtime state is memory/WebSocket-only. p.updateChildrenStatus(job.DeviceID, status) - } else { - p.logDebug("AirMAX %s: NOT updating DB (leftOnline=%v, unreachable=%v, status=%s)", job.IP, leftOnline, unreachable, status) } return pollNotThisType // Auth failed - might not be AirMAX } @@ -157,7 +135,6 @@ func (p *Poller) pollDeviceAirMAX(job pollJob) pollResult { if leftOnline { p.updateChildrenStatus(job.DeviceID, "unknown") } - dbExecIgnoreCtx(p.db, dbCtxForJob(job, "airmax_mark_unknown_status_failed"), `UPDATE devices SET status = 'unknown', status_reason = $2, last_seen = NOW() WHERE id = $1`, job.DeviceID, "status_failed") return pollFailed // Auth succeeded but status failed } @@ -266,7 +243,6 @@ func (p *Poller) pollDeviceAirMAX(job pollJob) pollResult { p.updateAirMAXDeviceInfo(job.DeviceID, job.IP, status, client, deviceStats.MAC) if becameOnline { p.clearIdentityMismatch(job.DeviceID) - dbExecIgnoreCtx(p.db, dbCtxForJob(job, "airmax_mark_online"), `UPDATE devices SET status = 'online', status_reason = NULL, last_seen = NOW() WHERE id = $1`, job.DeviceID) } } // If already online and nothing changed, no DB write needed diff --git a/internal/poller/canonical_mac.go b/internal/poller/canonical_mac.go index 807aefd..70ca9eb 100644 --- a/internal/poller/canonical_mac.go +++ b/internal/poller/canonical_mac.go @@ -171,8 +171,7 @@ func (p *Poller) handleMACMismatch(job pollJob, api string, observed []string) p }) } - // Persist status_reason to DB, but do NOT touch last_seen. - dbExecIgnoreCtx(p.db, dbCtxForJob(job, api+"_mac_mismatch"), `UPDATE devices SET status = 'unknown', status_reason = $2 WHERE id = $1`, job.DeviceID, "mac_mismatch") + // Persist durable mismatch evidence, not runtime status. p.persistIdentityMismatch(job, api, expected, observed, errMsg) p.updateChildrenStatus(job.DeviceID, "unknown") diff --git a/internal/poller/poller_core.go b/internal/poller/poller_core.go index bc337d6..316da33 100644 --- a/internal/poller/poller_core.go +++ b/internal/poller/poller_core.go @@ -471,49 +471,45 @@ func (p *Poller) getDeviceStatus(ip string, unreachable bool) string { return "unknown" } -// batchSyncToDB syncs last_seen and status to database periodically -// This provides persistence for crash recovery without per-poll DB writes -func (p *Poller) batchSyncToDB() { +// syncLastSeenToDB persists only a coarse "last available" marker. +// Real-time status is memory-only. This runs infrequently so the devices +// inventory table does not become a telemetry write stream. +func (p *Poller) syncLastSeenToDB() { lastSeenBatch := p.store.LastSeenBatch() - statusBatch := p.store.OnlineStatusBatch() - if len(lastSeenBatch) == 0 { return } - // Build batch update - one query for online, one for offline - onlineMACs := make([]string, 0) - offlineMACs := make([]string, 0) - - for mac, online := range statusBatch { - if online { - onlineMACs = append(onlineMACs, mac) - } else { - offlineMACs = append(offlineMACs, mac) - } + now := time.Now() + freshWindow := 5 * time.Minute + if interval := p.cfgSnapshot().interval * 3; interval > freshWindow { + freshWindow = interval } - // Update online devices - if len(onlineMACs) > 0 { - _, err := dbExecCtx(p.db, dbCtxForOp("batch_sync_last_seen"), `UPDATE devices SET last_seen = NOW() WHERE mac = ANY($1)`, pq.Array(onlineMACs)) - if err != nil { - p.logDebug("batchSyncToDB: online update failed: %v", err) + recentMACs := make([]string, 0, len(lastSeenBatch)) + for mac, lastSeen := range lastSeenBatch { + if mac == "" || lastSeen.IsZero() { + continue } - } - - // Update offline devices (with their actual last_seen time from memory) - // This is more complex - we need individual updates or a CTE - // For simplicity, we'll just ensure status is correct - // Important: only transition from 'online' to 'offline', not from 'unknown' to 'offline' - // Devices with 'unknown' status responded somehow (e.g., auth failed) so they're reachable - if len(offlineMACs) > 0 { - _, err := dbExecCtx(p.db, dbCtxForOp("batch_sync_mark_offline"), `UPDATE devices SET status = 'offline' WHERE mac = ANY($1) AND status = 'online'`, pq.Array(offlineMACs)) - if err != nil { - p.logDebug("batchSyncToDB: offline update failed: %v", err) + if now.Sub(lastSeen) <= freshWindow { + recentMACs = append(recentMACs, mac) } } + if len(recentMACs) == 0 { + return + } - p.logDebug("batchSyncToDB: synced %d online, %d offline devices", len(onlineMACs), len(offlineMACs)) + _, err := dbExecCtx(p.db, dbCtxForOp("sync_last_seen"), ` + UPDATE devices + SET last_seen = NOW() + WHERE mac = ANY($1) + AND (last_seen IS NULL OR last_seen < NOW() - INTERVAL '55 minutes') + `, pq.Array(recentMACs)) + if err != nil { + p.logDebug("syncLastSeenToDB: update failed: %v", err) + return + } + p.logDebug("syncLastSeenToDB: refreshed %d recently available devices", len(recentMACs)) } // cleanCircuitBreakers removes old entries @@ -557,18 +553,20 @@ func (p *Poller) Start(ctx context.Context) { // Initial poll p.pollAllDevices() - // Main poll loop with dynamic interval support + // Main poll loop with dynamic interval support. ticker := time.NewTicker(p.cfgSnapshot().interval) defer ticker.Stop() + lastSeenTicker := time.NewTicker(time.Hour) + defer lastSeenTicker.Stop() - // Cleanup stale STAs every 5 poll cycles, circuit breakers every 10, DB sync every 20 + // Cleanup stale STAs every 5 poll cycles and circuit breakers every 10. cleanupCounter := 0 for { select { case <-ctx.Done(): - // Final sync before shutdown - p.batchSyncToDB() + // One final coarse availability sync before shutdown. + p.syncLastSeenToDB() close(p.jobs) p.wg.Wait() return @@ -576,6 +574,8 @@ func (p *Poller) Start(ctx context.Context) { // Reset ticker with new interval ticker.Reset(newInterval) p.logDebug("Poll interval changed to %v", newInterval) + case <-lastSeenTicker.C: + p.syncLastSeenToDB() case <-ticker.C: p.pollAllDevices() @@ -590,11 +590,6 @@ func (p *Poller) Start(ctx context.Context) { if cleanupCounter%10 == 0 { p.cleanCircuitBreakers() } - // Batch sync last_seen to DB every 20 cycles (~10 min) - // This provides persistence without per-poll writes - if cleanupCounter%20 == 0 { - p.batchSyncToDB() - } } } } diff --git a/internal/poller/wave_poll.go b/internal/poller/wave_poll.go index d833788..9edf3f5 100644 --- a/internal/poller/wave_poll.go +++ b/internal/poller/wave_poll.go @@ -56,7 +56,6 @@ func (p *Poller) pollDeviceWave(job pollJob) pollResult { if leftOnline { p.updateChildrenStatus(job.DeviceID, "unknown") } - dbExecIgnoreCtx(p.db, dbCtxForJob(job, "wave_mark_unknown_auth"), `UPDATE devices SET status = 'unknown', status_reason = $2, last_seen = NOW() WHERE id = $1`, job.DeviceID, reason) return pollFailed } } @@ -81,7 +80,6 @@ func (p *Poller) pollDeviceWave(job pollJob) pollResult { if leftOnline { p.updateChildrenStatus(job.DeviceID, "unknown") } - dbExecIgnoreCtx(p.db, dbCtxForJob(job, "wave_mark_unknown_stats_failed"), `UPDATE devices SET status = 'unknown', status_reason = $2, last_seen = NOW() WHERE id = $1`, job.DeviceID, "stats_failed") return pollFailed // We authenticated, so it's a Wave device, but stats failed } @@ -380,17 +378,10 @@ func (p *Poller) pollDeviceWave(job pollJob) pollResult { // Only write to DB on state transition or hostname change (not every poll) if becameOnline { p.clearIdentityMismatch(job.DeviceID) - // Device came online - update status in DB - if hostnameChanged { - dbExecIgnoreCtx(p.db, dbCtxForJob(job, "wave_mark_online"), `UPDATE devices SET status = 'online', status_reason = NULL, last_seen = NOW(), hostname = $2 WHERE id = $1`, job.DeviceID, deviceStats.Hostname) - } else { - dbExecIgnoreCtx(p.db, dbCtxForJob(job, "wave_mark_online"), `UPDATE devices SET status = 'online', status_reason = NULL, last_seen = NOW() WHERE id = $1`, job.DeviceID) - } - } else if hostnameChanged { - // Already online but hostname changed - dbExecIgnoreCtx(p.db, dbCtxForJob(job, "wave_update_hostname"), `UPDATE devices SET hostname = $2 WHERE id = $1`, job.DeviceID, deviceStats.Hostname) } - // If already online and nothing changed, no DB write needed + if hostnameChanged { + dbExecIgnoreCtx(p.db, dbCtxForJob(job, "wave_update_hostname"), `UPDATE devices SET hostname = $2 WHERE id = $1 AND hostname IS DISTINCT FROM $2`, job.DeviceID, deviceStats.Hostname) + } // Check if static info changed (firmware, additional hostname sources) p.checkStaticInfo(client, baseURL, token, job.DeviceID, job.MAC) From b000bf2a1f70b0414229ecef08e9f2055acb4f9f Mon Sep 17 00:00:00 2001 From: Chris Cappuccio Date: Fri, 18 Sep 2026 18:14:44 -0700 Subject: [PATCH 02/10] Stop STA association polls from rewriting inventory --- internal/poller/airmax_poll.go | 10 ++-- internal/poller/children.go | 88 +++++++++++++++------------------- 2 files changed, 43 insertions(+), 55 deletions(-) diff --git a/internal/poller/airmax_poll.go b/internal/poller/airmax_poll.go index bc71a9f..847db3b 100644 --- a/internal/poller/airmax_poll.go +++ b/internal/poller/airmax_poll.go @@ -730,8 +730,8 @@ func (p *Poller) updateAirMAXDeviceInfo(deviceID int64, ip string, status *airma ssid = $9, frequency = $10, channel_width = $11, - gps_lat = $12, - gps_lon = $13 + gps_lat = COALESCE(gps_lat, $12), + gps_lon = COALESCE(gps_lon, $13) WHERE id = $14 AND ( hostname IS DISTINCT FROM $1 OR product IS DISTINCT FROM $3 OR @@ -743,8 +743,8 @@ func (p *Poller) updateAirMAXDeviceInfo(deviceID int64, ip string, status *airma ssid IS DISTINCT FROM $9 OR frequency IS DISTINCT FROM $10 OR channel_width IS DISTINCT FROM $11 OR - gps_lat IS DISTINCT FROM $12 OR - gps_lon IS DISTINCT FROM $13 OR + (gps_lat IS NULL AND $12 IS NOT NULL) OR + (gps_lon IS NULL AND $13 IS NOT NULL) OR ( NULLIF($2, '') IS NOT NULL AND NOT EXISTS (SELECT 1 FROM devices d2 WHERE d2.mac = $2 AND d2.id <> $14) @@ -787,8 +787,6 @@ func (p *Poller) updateAirMAXDeviceInfo(deviceID int64, ip string, status *airma "ssid": ssid, "frequency": status.Wireless.GetFrequency(), "channel_width": status.Wireless.GetChanBW(), - "gps_lat": lat, - "gps_lon": lon, } if !macConflict && mac != "" { patch["mac"] = mac diff --git a/internal/poller/children.go b/internal/poller/children.go index d9f256a..c2c5405 100644 --- a/internal/poller/children.go +++ b/internal/poller/children.go @@ -12,25 +12,22 @@ import ( "github.com/yellowman/wavecontrol/internal/websocket" ) -// updateChildrenStatus updates status of all STAs associated with an AP. -// Database, in-memory stats, and WebSocket state are updated from the same -// RETURNING rows so the application cannot present contradictory child status. +// updateChildrenStatus updates live status of all STAs associated with an AP. +// PostgreSQL supplies stable identity/hierarchy only; operational status stays +// in the in-memory stats store and is broadcast over WebSocket. func (p *Poller) updateChildrenStatus(apID int64, status string) { reason := "" if status != string(stats.StatusOnline) { reason = "parent_" + status } rows, err := p.db.Query(` - UPDATE devices - SET status = $1, - status_reason = NULLIF($2, '') - WHERE parent_id = $3 + SELECT id, COALESCE(lower(mac), ''), COALESCE(host(ip_address), ''), COALESCE(site_id, 0) + FROM devices + WHERE parent_id = $1 AND role = 'sta' - AND (status IS DISTINCT FROM $1 OR status_reason IS DISTINCT FROM NULLIF($2, '')) - RETURNING id, COALESCE(lower(mac), ''), COALESCE(host(ip_address), ''), COALESCE(site_id, 0) - `, status, reason, apID) + `, apID) if err != nil { - p.logDebug("updateChildrenStatus: failed to update children of AP %d: %v", apID, err) + p.logDebug("updateChildrenStatus: failed to load children of AP %d: %v", apID, err) return } defer rows.Close() @@ -57,9 +54,10 @@ func (p *Poller) updateChildrenStatus(apID int64, status string) { p.logDebug("updateChildrenStatus: iterate children of AP %d: %v", apID, err) } if count > 0 { - p.logDebug("updateChildrenStatus: updated %d children of AP %d to status '%s'", count, apID, status) + p.logDebug("updateChildrenStatus: updated live state for %d children of AP %d to '%s'", count, apID, status) } } + func (p *Poller) updateSTAsInDB(apID int64, peers []*stats.PeerStats, ipChanges map[string]string) { // Get AP's MAC, site_id, SSID and platform for inheritance var apMAC sql.NullString @@ -253,10 +251,7 @@ func (p *Poller) updateSTAsInDB(apID int64, peers []*stats.PeerStats, ipChanges parent_mac = $8, ssid = COALESCE(NULLIF($9, ''), ssid), role = 'sta', - site_id = NULL, - status = 'online', - status_reason = NULL, - last_seen = NOW() + site_id = NULL WHERE lower(mac) = $10 `, newIP, hostname, model, plat, flv, fw, apID, apMAC.String, ssid, peerMAC) } else { @@ -271,10 +266,7 @@ func (p *Poller) updateSTAsInDB(apID int64, peers []*stats.PeerStats, ipChanges parent_mac = $7, ssid = COALESCE(NULLIF($8, ''), ssid), role = 'sta', - site_id = NULL, - status = 'online', - status_reason = NULL, - last_seen = NOW() + site_id = NULL WHERE lower(mac) = $9 `, hostname, model, plat, flv, fw, apID, apMAC.String, ssid, peerMAC) } @@ -300,10 +292,7 @@ func (p *Poller) updateSTAsInDB(apID int64, peers []*stats.PeerStats, ipChanges parent_mac = $8, ssid = COALESCE(NULLIF($9, ''), ssid), site_id = COALESCE(site_id, $10), - role = 'sta', - status = 'online', - status_reason = NULL, - last_seen = NOW() + role = 'sta' WHERE lower(mac) = $11 `, newIP, hostname, model, plat, flv, fw, apID, apMAC.String, ssid, newSiteID, peerMAC) } else { @@ -318,11 +307,20 @@ func (p *Poller) updateSTAsInDB(apID int64, peers []*stats.PeerStats, ipChanges parent_mac = $7, ssid = COALESCE(NULLIF($8, ''), ssid), site_id = COALESCE(site_id, $9), - role = 'sta', - status = 'online', - status_reason = NULL, - last_seen = NOW() + role = 'sta' WHERE lower(mac) = $10 + AND ( + hostname IS DISTINCT FROM COALESCE(NULLIF($1, ''), hostname) + OR model IS DISTINCT FROM COALESCE(NULLIF($2, ''), model) + OR platform IS DISTINCT FROM COALESCE(NULLIF($3, ''), platform) + OR flavor IS DISTINCT FROM COALESCE(NULLIF($4, ''), flavor) + OR firmware IS DISTINCT FROM COALESCE(NULLIF($5, ''), firmware) + OR parent_id IS DISTINCT FROM $6 + OR parent_mac IS DISTINCT FROM $7 + OR ssid IS DISTINCT FROM COALESCE(NULLIF($8, ''), ssid) + OR site_id IS DISTINCT FROM COALESCE(site_id, $9) + OR role IS DISTINCT FROM 'sta' + ) `, hostname, model, plat, flv, fw, apID, apMAC.String, ssid, newSiteID, peerMAC) } } @@ -393,8 +391,8 @@ func (p *Poller) updateSTAsInDB(apID int64, peers []*stats.PeerStats, ipChanges ctx := dbCtxForMAC(peerMAC, staHost, "sta_upsert", 0) if ipChanged { err = dbQueryRowCtx(p.db, ctx, ` - INSERT INTO devices (mac, ip_address, hostname, model, platform, flavor, firmware, parent_id, parent_mac, ssid, site_id, role, alertable, status, status_reason, last_seen) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, 'sta', FALSE, 'online', NULL, NOW()) + INSERT INTO devices (mac, ip_address, hostname, model, platform, flavor, firmware, parent_id, parent_mac, ssid, site_id, role, alertable, last_seen) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, 'sta', FALSE, NOW()) ON CONFLICT (mac) DO UPDATE SET ip_address = CASE WHEN devices.role = 'ap' THEN devices.ip_address ELSE EXCLUDED.ip_address END, hostname = CASE WHEN devices.role = 'ap' THEN devices.hostname ELSE COALESCE(NULLIF(EXCLUDED.hostname, ''), devices.hostname) END, @@ -406,16 +404,13 @@ func (p *Poller) updateSTAsInDB(apID int64, peers []*stats.PeerStats, ipChanges parent_mac = CASE WHEN devices.role = 'ap' THEN NULL ELSE EXCLUDED.parent_mac END, ssid = CASE WHEN devices.role = 'ap' THEN devices.ssid ELSE COALESCE(NULLIF(EXCLUDED.ssid, ''), devices.ssid) END, site_id = CASE WHEN devices.role = 'ap' THEN devices.site_id ELSE COALESCE(devices.site_id, EXCLUDED.site_id) END, - role = CASE WHEN devices.role = 'ap' THEN devices.role ELSE 'sta' END, - status = CASE WHEN devices.role = 'ap' THEN devices.status ELSE 'online' END, - status_reason = CASE WHEN devices.role = 'ap' THEN devices.status_reason ELSE NULL END, - last_seen = CASE WHEN devices.role = 'ap' THEN devices.last_seen ELSE NOW() END + role = CASE WHEN devices.role = 'ap' THEN devices.role ELSE 'sta' END RETURNING id, (xmax = 0) AS inserted `, []any{&newID, &inserted}, peerMAC, newIP, hostname, model, plat, flv, fw, apID, apMAC.String, ssid, apSiteID) } else { err = dbQueryRowCtx(p.db, ctx, ` - INSERT INTO devices (mac, ip_address, hostname, model, platform, flavor, firmware, parent_id, parent_mac, ssid, site_id, role, alertable, status, status_reason, last_seen) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, 'sta', FALSE, 'online', NULL, NOW()) + INSERT INTO devices (mac, ip_address, hostname, model, platform, flavor, firmware, parent_id, parent_mac, ssid, site_id, role, alertable, last_seen) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, 'sta', FALSE, NOW()) ON CONFLICT (mac) DO UPDATE SET hostname = CASE WHEN devices.role = 'ap' THEN devices.hostname ELSE COALESCE(NULLIF(EXCLUDED.hostname, ''), devices.hostname) END, model = CASE WHEN devices.role = 'ap' THEN devices.model ELSE COALESCE(NULLIF(EXCLUDED.model, ''), devices.model) END, @@ -426,10 +421,7 @@ func (p *Poller) updateSTAsInDB(apID int64, peers []*stats.PeerStats, ipChanges parent_mac = CASE WHEN devices.role = 'ap' THEN NULL ELSE EXCLUDED.parent_mac END, ssid = CASE WHEN devices.role = 'ap' THEN devices.ssid ELSE COALESCE(NULLIF(EXCLUDED.ssid, ''), devices.ssid) END, site_id = CASE WHEN devices.role = 'ap' THEN devices.site_id ELSE COALESCE(devices.site_id, EXCLUDED.site_id) END, - role = CASE WHEN devices.role = 'ap' THEN devices.role ELSE 'sta' END, - status = CASE WHEN devices.role = 'ap' THEN devices.status ELSE 'online' END, - status_reason = CASE WHEN devices.role = 'ap' THEN devices.status_reason ELSE NULL END, - last_seen = CASE WHEN devices.role = 'ap' THEN devices.last_seen ELSE NOW() END + role = CASE WHEN devices.role = 'ap' THEN devices.role ELSE 'sta' END RETURNING id, (xmax = 0) AS inserted `, []any{&newID, &inserted}, peerMAC, ipToStore, hostname, model, plat, flv, fw, apID, apMAC.String, ssid, apSiteID) } @@ -484,21 +476,17 @@ func (p *Poller) updateSTAsInDB(apID int64, peers []*stats.PeerStats, ipChanges } func (p *Poller) markMissingSTAsOffline(apID int64, associatedMACs []string) { - // Use a case-insensitive match for safety when legacy rows have uppercase MACs. - // An empty list reaches this function only after the empty-snapshot debounce has - // confirmed two consecutive authoritative empty AP responses. + // Missing association is transient operational state. Query stable child + // identities but do not write status back to the inventory table. rows, err := p.db.Query(` - UPDATE devices - SET status = 'offline', - status_reason = 'not_associated' + SELECT id, COALESCE(lower(mac), ''), COALESCE(host(ip_address), ''), COALESCE(site_id, 0) + FROM devices WHERE parent_id = $1 AND role = 'sta' AND NOT (lower(mac) = ANY($2::text[])) - AND (status IS DISTINCT FROM 'offline' OR status_reason IS DISTINCT FROM 'not_associated') - RETURNING id, COALESCE(lower(mac), ''), COALESCE(host(ip_address), ''), COALESCE(site_id, 0) `, apID, pq.Array(associatedMACs)) if err != nil { - logDBExecError(dbCtxForDevice(apID, "mark_missing_stas_offline"), err, "UPDATE devices ... RETURNING", []any{apID, associatedMACs}, nil) + logDBExecError(dbCtxForDevice(apID, "mark_missing_stas_offline"), err, "SELECT child devices", []any{apID, associatedMACs}, nil) return } defer rows.Close() @@ -521,4 +509,6 @@ func (p *Poller) markMissingSTAsOffline(apID int64, associatedMACs []string) { if err := rows.Err(); err != nil { p.logDebug("markMissingSTAsOffline: iterate children of AP %d: %v", apID, err) } + +} } From d894fda957c6365b14c506c02526726dcb99a6e7 Mon Sep 17 00:00:00 2001 From: Chris Cappuccio Date: Fri, 18 Sep 2026 18:15:10 -0700 Subject: [PATCH 03/10] Fix STA poller function boundary --- internal/poller/children.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/poller/children.go b/internal/poller/children.go index c2c5405..58e3290 100644 --- a/internal/poller/children.go +++ b/internal/poller/children.go @@ -511,4 +511,3 @@ func (p *Poller) markMissingSTAsOffline(apID int64, associatedMACs []string) { } } -} From e5f8a884fe878a37b1e295d5a4dec1decc26114e Mon Sep 17 00:00:00 2001 From: Chris Cappuccio Date: Fri, 18 Sep 2026 18:18:13 -0700 Subject: [PATCH 04/10] Stop serving stale device status from PostgreSQL --- cmd/server/api.go | 55 ++++++++++++++++++++++++----------------------- 1 file changed, 28 insertions(+), 27 deletions(-) diff --git a/cmd/server/api.go b/cmd/server/api.go index f9d7dc4..895dfb4 100644 --- a/cmd/server/api.go +++ b/cmd/server/api.go @@ -421,7 +421,7 @@ func (a *API) ListDevices(w http.ResponseWriter, r *http.Request) { d.hostname, d.product, d.model, d.platform, d.flavor, d.firmware, d.firmware_version, d.parent_id, lower(d.parent_mac) AS parent_mac, - d.status, d.status_reason, d.last_seen, d.role, d.managed, d.alertable, d.alert_silenced_until, d.alert_notes, d.ssid, d.frequency, d.channel_width, d.gps_lat, d.gps_lon, + d.last_seen, d.role, d.managed, d.alertable, d.alert_silenced_until, d.alert_notes, d.ssid, d.frequency, d.channel_width, d.gps_lat, d.gps_lon, d.antenna_model, d.antenna_override, d.antenna_azimuth_deg, d.antenna_downtilt_deg, d.antenna_electrical_downtilt_deg, d.antenna_beamwidth_h_deg, d.antenna_beamwidth_v_deg, d.radius_m, d.tech, d.down_mbps, d.up_mbps, d.latency_ms, d.bizres, d.site_id, s.name as site_name, r.name as region_name @@ -441,7 +441,7 @@ func (a *API) ListDevices(w http.ResponseWriter, r *http.Request) { for rows.Next() { var id int64 - var mac, ipAddr, hostname, product, model, platform, flavor, fw, fwVer, parentMAC, dbStatus, dbStatusReason, role, ssid sql.NullString + var mac, ipAddr, hostname, product, model, platform, flavor, fw, fwVer, parentMAC, role, ssid sql.NullString var managed, alertable bool var alertSilencedUntil sql.NullTime var alertNotes sql.NullString @@ -457,7 +457,7 @@ func (a *API) ListDevices(w http.ResponseWriter, r *http.Request) { var tech sql.NullInt64 var bizres sql.NullString if rows.Scan(&id, &mac, &ipAddr, &hostname, &product, &model, &platform, &flavor, &fw, &fwVer, - &parentID, &parentMAC, &dbStatus, &dbStatusReason, &lastSeen, &role, &managed, &alertable, &alertSilencedUntil, &alertNotes, &ssid, &frequency, &channelWidth, &gpsLat, &gpsLon, + &parentID, &parentMAC, &lastSeen, &role, &managed, &alertable, &alertSilencedUntil, &alertNotes, &ssid, &frequency, &channelWidth, &gpsLat, &gpsLon, &antennaModel, &antennaOverride, &antennaAzimuthDeg, &antennaDowntiltDeg, &antennaElectricalDowntiltDeg, &antennaBeamH, &antennaBeamV, &radiusM, &tech, &downMbps, &upMbps, &latencyMS, &bizres, &siteID, &siteName, ®ionName) != nil { @@ -469,12 +469,12 @@ func (a *API) ListDevices(w http.ResponseWriter, r *http.Request) { d := map[string]any{"id": id, "mac": mac.String, "ip_address": ipHost, "hostname": hostname.String, "product": product.String, "model": model.String, "platform": platform.String, "flavor": flavor.String, "firmware": fw.String, "firmware_version": fwVer.String, - // Persisted DB status (source of truth when we have no live stats) - "db_status": dbStatus.String, - "db_status_reason": dbStatusReason.String, - // Live/computed status defaults to DB status and may be overridden below - "status": dbStatus.String, - "status_reason": dbStatusReason.String, + // Runtime status is memory-only. db_status remains a browser + // compatibility alias and is never loaded from PostgreSQL. + "db_status": "unknown", + "db_status_reason": "", + "status": "unknown", + "status_reason": "", "role": role.String, "managed": managed, "alertable": alertable, @@ -572,15 +572,12 @@ func (a *API) ListDevices(w http.ResponseWriter, r *http.Request) { if liveStats != nil { d["online"] = liveStats.Online - // Prefer live tri-state status over DB status d["status"] = string(liveStats.Status) - if liveStats.StatusReason != "" { - d["status_reason"] = liveStats.StatusReason - } else { - // Clear stale DB reasons when device is currently healthy - if liveStats.Online { - d["status_reason"] = "" - } + d["db_status"] = string(liveStats.Status) + d["status_reason"] = liveStats.StatusReason + d["db_status_reason"] = liveStats.StatusReason + if !liveStats.LastSeen.IsZero() { + d["last_seen"] = liveStats.LastSeen } d["uptime"] = liveStats.Uptime d["peer_count"] = liveStats.PeerCount @@ -639,9 +636,6 @@ func (a *API) ListDevices(w http.ResponseWriter, r *http.Request) { if liveStats.Config != nil { d["config"] = liveStats.Config } - } else { - // No live stats - set online based on db_status for consistent client-side checking - d["online"] = dbStatus.String == "online" } devices = append(devices, d) } @@ -1118,7 +1112,6 @@ func (a *API) upsertDiscoveredDevice(device *DeviceInfo, ip, username, password site_id = COALESCE($11, devices.site_id), managed = TRUE, alertable = TRUE, - status = 'online', last_seen = NOW(), username = $12, password = $13 @@ -1130,8 +1123,8 @@ func (a *API) upsertDiscoveredDevice(device *DeviceInfo, ip, username, password } case selErr == sql.ErrNoRows: err := a.DB.QueryRow(` - INSERT INTO devices (mac, ip_address, hostname, product, model, platform, flavor, firmware, firmware_version, site_id, managed, alertable, status, last_seen, username, password) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, TRUE, TRUE, 'online', NOW(), $11, $12) + INSERT INTO devices (mac, ip_address, hostname, product, model, platform, flavor, firmware, firmware_version, site_id, managed, alertable, last_seen, username, password) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, TRUE, TRUE, NOW(), $11, $12) RETURNING id `, mac, ip, hostname, product, model, platform, flavor, firmware, firmwareVersion, siteID, username, storedPassword).Scan(&id) if err != nil { @@ -1404,7 +1397,7 @@ func (a *API) loadCredentials() (apCreds, staCreds []Credential) { func (a *API) GetDevice(w http.ResponseWriter, r *http.Request) { id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) - var mac, ipAddr, hostname, product, model, platform, flavor, fw, fwVer, parentMAC, dbStatus, dbStatusReason sql.NullString + var mac, ipAddr, hostname, product, model, platform, flavor, fw, fwVer, parentMAC sql.NullString var managed, alertable bool var alertSilencedUntil sql.NullTime var alertNotes sql.NullString @@ -1418,18 +1411,18 @@ func (a *API) GetDevice(w http.ResponseWriter, r *http.Request) { var lastSeen sql.NullTime err := a.DB.QueryRow(`SELECT mac, host(ip_address), hostname, product, model, platform, flavor, firmware, firmware_version, managed, alertable, alert_silenced_until, alert_notes, - parent_id, parent_mac, status, status_reason, last_seen, + parent_id, parent_mac, last_seen, antenna_model, antenna_override, antenna_azimuth_deg, antenna_downtilt_deg, antenna_electrical_downtilt_deg, antenna_beamwidth_h_deg, antenna_beamwidth_v_deg, radius_m, tech, down_mbps, up_mbps, latency_ms, bizres FROM devices WHERE id = $1`, id). - Scan(&mac, &ipAddr, &hostname, &product, &model, &platform, &flavor, &fw, &fwVer, &managed, &alertable, &alertSilencedUntil, &alertNotes, &parentID, &parentMAC, &dbStatus, &dbStatusReason, &lastSeen, + Scan(&mac, &ipAddr, &hostname, &product, &model, &platform, &flavor, &fw, &fwVer, &managed, &alertable, &alertSilencedUntil, &alertNotes, &parentID, &parentMAC, &lastSeen, &antennaModel, &antennaOverride, &antennaAzimuthDeg, &antennaDowntiltDeg, &antennaElectricalDowntiltDeg, &antennaBeamH, &antennaBeamV, &radiusM, &tech, &downMbps, &upMbps, &latencyMS, &bizres) if err == sql.ErrNoRows { http.Error(w, "not found", 404) return } - d := map[string]any{"id": id, "mac": mac.String, "ip_address": ipAddr.String, "hostname": hostname.String, "product": product.String, "model": model.String, "platform": platform.String, "flavor": flavor.String, "firmware": fw.String, "firmware_version": fwVer.String, "db_status": dbStatus.String, "db_status_reason": dbStatusReason.String, "status": dbStatus.String, "status_reason": dbStatusReason.String, "managed": managed, "alertable": alertable, "alert_notes": alertNotes.String} + d := map[string]any{"id": id, "mac": mac.String, "ip_address": ipAddr.String, "hostname": hostname.String, "product": product.String, "model": model.String, "platform": platform.String, "flavor": flavor.String, "firmware": fw.String, "firmware_version": fwVer.String, "db_status": "unknown", "db_status_reason": "", "status": "unknown", "status_reason": "", "managed": managed, "alertable": alertable, "alert_notes": alertNotes.String} // Optional antenna modeling fields d["antenna_model"] = antennaModel.String @@ -1494,6 +1487,14 @@ func (a *API) GetDevice(w http.ResponseWriter, r *http.Request) { } if liveStats != nil { d["live_stats"] = liveStats + d["online"] = liveStats.Online + d["status"] = string(liveStats.Status) + d["db_status"] = string(liveStats.Status) + d["status_reason"] = liveStats.StatusReason + d["db_status_reason"] = liveStats.StatusReason + if !liveStats.LastSeen.IsZero() { + d["last_seen"] = liveStats.LastSeen + } } a.attachIdentityMismatch(d, id) writeJSON(w, d) From 695d9d3c4385924e7ede1a5d4d99ca3d79844cf5 Mon Sep 17 00:00:00 2001 From: Chris Cappuccio Date: Fri, 18 Sep 2026 18:18:46 -0700 Subject: [PATCH 05/10] Use durable mismatch records instead of DB status --- cmd/server/identity_mismatch_api.go | 16 ++++------------ schema.sql | 8 +++++--- 2 files changed, 9 insertions(+), 15 deletions(-) diff --git a/cmd/server/identity_mismatch_api.go b/cmd/server/identity_mismatch_api.go index 5118db7..f9fdc40 100644 --- a/cmd/server/identity_mismatch_api.go +++ b/cmd/server/identity_mismatch_api.go @@ -130,13 +130,13 @@ func (a *API) LearnDeviceMAC(w http.ResponseWriter, r *http.Request) { } defer tx.Rollback() - var oldMAC, ipAddr, statusReason, role, hostname sql.NullString + var oldMAC, ipAddr, role, hostname sql.NullString var parentID sql.NullInt64 err = tx.QueryRowContext(r.Context(), ` - SELECT lower(mac), host(ip_address), status_reason, role, hostname, parent_id + SELECT lower(mac), host(ip_address), role, hostname, parent_id FROM devices WHERE id = $1 - FOR UPDATE`, id).Scan(&oldMAC, &ipAddr, &statusReason, &role, &hostname, &parentID) + FOR UPDATE`, id).Scan(&oldMAC, &ipAddr, &role, &hostname, &parentID) if err == sql.ErrNoRows { http.Error(w, "device not found", http.StatusNotFound) return @@ -167,14 +167,6 @@ func (a *API) LearnDeviceMAC(w http.ResponseWriter, r *http.Request) { return } - if !strings.EqualFold(statusReason.String, "mac_mismatch") { - writeJSONStatus(w, http.StatusConflict, map[string]any{ - "error": "device_not_in_mac_mismatch", - "status_reason": statusReason.String, - }) - return - } - oldCanon, err := normalizeCanonicalMAC(oldMAC.String) if err != nil { writeJSONStatus(w, http.StatusConflict, map[string]any{"error": "current_device_mac_invalid"}) @@ -252,7 +244,7 @@ func (a *API) LearnDeviceMAC(w http.ResponseWriter, r *http.Request) { if _, err := tx.ExecContext(r.Context(), ` UPDATE devices - SET mac = $2, status = 'unknown', status_reason = NULL, updated_at = NOW() + SET mac = $2, updated_at = NOW() WHERE id = $1`, id, newCanon); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return diff --git a/schema.sql b/schema.sql index 2b71585..e71e9cb 100644 --- a/schema.sql +++ b/schema.sql @@ -172,9 +172,11 @@ CREATE TABLE IF NOT EXISTS devices ( username VARCHAR(64), password TEXT, - -- Status tracking (basic, real-time stats in memory) - status VARCHAR(16) DEFAULT 'unknown', -- online, offline, upgrading, unknown - status_reason VARCHAR(128), -- short reason for unknown/offline (optional) + -- Runtime status is kept in memory. These columns remain only for schema + -- compatibility and are not read/written by the poller. + status VARCHAR(16) DEFAULT 'unknown', + status_reason VARCHAR(128), + -- Coarse durable "last available" marker, updated infrequently in batches. last_seen TIMESTAMP, created_at TIMESTAMP DEFAULT NOW(), From e6075667ae89ecd5b1a708f5b8bf0396de98ecf5 Mon Sep 17 00:00:00 2001 From: Chris Cappuccio Date: Fri, 18 Sep 2026 18:19:09 -0700 Subject: [PATCH 06/10] Build reports from live device status --- cmd/server/reports_v2.go | 35 +++++++++++++++++++++++------------ 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/cmd/server/reports_v2.go b/cmd/server/reports_v2.go index fde5421..8b4e2e4 100644 --- a/cmd/server/reports_v2.go +++ b/cmd/server/reports_v2.go @@ -41,7 +41,6 @@ type reportInventoryDevice struct { Product string Firmware string Flavor string - Status string Platform string Region string Site string @@ -75,10 +74,17 @@ func normalizeReportStatus(value string) string { } } +func reportLiveStatus(live *stats.DeviceStats) string { + if live == nil { + return "unknown" + } + return normalizeReportStatus(string(live.Status)) +} + func (a *API) loadReportInventory(ctx context.Context) ([]reportInventoryDevice, error) { rows, err := a.DB.QueryContext(ctx, ` SELECT d.id, d.hostname, host(d.ip_address), d.mac, d.product, d.firmware, - d.flavor, d.status, d.platform, d.parent_id, + d.flavor, d.platform, d.parent_id, p.hostname, host(p.ip_address), r.name, s.name, d.last_seen FROM devices d LEFT JOIN devices p ON d.parent_id = p.id @@ -94,13 +100,13 @@ func (a *API) loadReportInventory(ctx context.Context) ([]reportInventoryDevice, devices := make([]reportInventoryDevice, 0) for rows.Next() { var d reportInventoryDevice - var hostname, product, firmware, flavor, status, platform sql.NullString + var hostname, product, firmware, flavor, platform sql.NullString var parentID sql.NullInt64 var parentHostname, parentIP, region, site sql.NullString var lastSeen sql.NullTime if err := rows.Scan( &d.ID, &hostname, &d.IP, &d.MAC, &product, &firmware, - &flavor, &status, &platform, &parentID, + &flavor, &platform, &parentID, &parentHostname, &parentIP, ®ion, &site, &lastSeen, ); err != nil { return nil, fmt.Errorf("inventory row scan failed: %w", err) @@ -109,7 +115,6 @@ func (a *API) loadReportInventory(ctx context.Context) ([]reportInventoryDevice, d.Product = product.String d.Firmware = firmware.String d.Flavor = flavor.String - d.Status = normalizeReportStatus(status.String) d.Platform = platform.String d.Region = region.String d.Site = site.String @@ -416,7 +421,8 @@ func (a *API) buildHealthReport(ctx context.Context) (map[string]any, error) { for _, device := range inventory { inventoryByIP[device.IP] = device - status := normalizeReportStatus(device.Status) + live := liveByMAC[strings.ToLower(device.MAC)] + status := reportLiveStatus(live) statusCounts[status]++ if device.IsSTA() { staCount++ @@ -457,7 +463,6 @@ func (a *API) buildHealthReport(ctx context.Context) (map[string]any, error) { site.APs++ } - live := liveByMAC[strings.ToLower(device.MAC)] if live != nil { metricDevices++ site.Metrics++ @@ -657,6 +662,7 @@ func (a *API) buildInventoryReport(ctx context.Context) (map[string]any, error) if err != nil { return nil, err } + liveByMAC := reportStatsByMAC(a.Stats) devices := make([]map[string]any, 0, len(inventory)) statusCounts := map[string]int{"online": 0, "offline": 0, "unknown": 0} @@ -667,7 +673,8 @@ func (a *API) buildInventoryReport(ctx context.Context) (map[string]any, error) siteAggregates := make(map[string]*reportSiteAggregate) for _, device := range inventory { - status := normalizeReportStatus(device.Status) + live := liveByMAC[strings.ToLower(device.MAC)] + status := reportLiveStatus(live) statusCounts[status]++ if device.IsSTA() { staCount++ @@ -722,8 +729,12 @@ func (a *API) buildInventoryReport(ctx context.Context) (map[string]any, error) row["parent_hostname"] = device.ParentHostname row["parent_ip"] = device.ParentIP } - if device.HasLastSeen { - row["last_seen"] = device.LastSeen + lastSeen := device.LastSeen + if live != nil && !live.LastSeen.IsZero() && (lastSeen.IsZero() || live.LastSeen.After(lastSeen)) { + lastSeen = live.LastSeen + } + if !lastSeen.IsZero() { + row["last_seen"] = lastSeen } devices = append(devices, row) } @@ -809,7 +820,7 @@ func (a *API) buildPerformanceReport(ctx context.Context) (map[string]any, error if live == nil { missingDevices = append(missingDevices, map[string]any{ "id": device.ID, "hostname": device.DisplayName(), "ip": device.IP, - "site": siteKey, "status": device.Status, "is_sta": device.IsSTA(), + "site": siteKey, "status": "unknown", "is_sta": device.IsSTA(), }) continue } @@ -874,7 +885,7 @@ func (a *API) buildPerformanceReport(ctx context.Context) (map[string]any, error memUsage = int(live.MemUsage) } - status := normalizeReportStatus(device.Status) + status := reportLiveStatus(live) row := map[string]any{ "id": device.ID, "ip": ip, "hostname": hostname, "product": device.Product, "flavor": device.Flavor, "platform": platformKey, "site": siteKey, "region": device.Region, From 05a9272039eb23320ea46fc7d3f86592b6832284 Mon Sep 17 00:00:00 2001 From: Chris Cappuccio Date: Fri, 18 Sep 2026 18:21:28 -0700 Subject: [PATCH 07/10] Document inventory-only device persistence --- SPEC.md | 84 +++++++++++++++++++++++++-------------------------------- 1 file changed, 37 insertions(+), 47 deletions(-) diff --git a/SPEC.md b/SPEC.md index cffdb30..869c86e 100644 --- a/SPEC.md +++ b/SPEC.md @@ -804,64 +804,54 @@ func (p *Poller) discoverSTAs(apIP string, creds Credentials) ([]STAInfo, error) // 4. Sleep until next interval ``` -### State-Transition Database Pattern +### Inventory-Only Device Database Pattern -**Principle**: Database writes only occur on state changes, not on every poll cycle. +**Principle**: the `devices` table is durable inventory/configuration, not a telemetry store. -This pattern dramatically reduces database I/O - with 5000 devices at 30-second polls, this eliminates ~600,000 unnecessary writes per hour. +Live availability, status reasons, uptime, CPU/RAM/temperature, radio metrics, peer +association state, counters, and other polling results live in the in-memory stats +store and are broadcast over WebSocket. A process restart intentionally starts device +status at `unknown` until fresh polls arrive; stale database status must never be +used as a fallback. -#### What Triggers a Database Write +#### What Triggers a `devices` Row Write | Event | Database Action | |-------|-----------------| -| Device comes online (was offline/unknown) | `UPDATE status='online', last_seen=NOW()` | -| Device goes offline (was online) | `UPDATE status='offline', last_seen=NOW()` | -| Hostname changes | `UPDATE hostname=?` | -| Firmware changes | `UPDATE firmware=?, firmware_version=?` | -| Other static info changes | Update via `IS DISTINCT FROM` check | +| Device/IP/MAC identity changes | Update the changed inventory field | +| Parent AP / SSID / role changes | Update durable hierarchy/configuration | +| Hostname/model/platform/flavor/firmware changes | Update only when `IS DISTINCT FROM` the stored value | +| Site / operator configuration changes | Persist the operator-owned value | +| Device first discovered | Insert its inventory row | +| Coarse availability checkpoint | Batch-update `last_seen` at most about once per hour | -#### What Does NOT Trigger a Database Write +#### What Does NOT Trigger a `devices` Row Write | Event | Where Data Lives | |-------|------------------| -| Successful poll (device already online) | Memory store only | -| Stats refresh (CPU, memory, signal, rates) | Memory store only | -| Peer list update | Memory store only | -| Real-time counters | Memory store only | +| Online/offline/unknown transition | In-memory stats store + WebSocket | +| Status reason / poll error | In-memory stats store + WebSocket | +| Successful poll | In-memory stats store | +| CPU, RAM, temperature, uptime | In-memory stats store | +| Signal, rates, capacity, MCS, airtime | In-memory stats store | +| Peer association/disassociation | In-memory stats store + WebSocket | +| Firmware upgrade runtime state | Job records + in-memory/WebSocket state | -#### Implementation Pattern +The legacy `devices.status` and `devices.status_reason` columns remain only for schema +compatibility. Polling code must not read or write them. -```go -// Update memory store - returns true if state changed (offline->online) -becameOnline := store.Update(ip, deviceStats) - -// Only write to DB on state transition -if becameOnline { - db.Exec(`UPDATE devices SET status = 'online', last_seen = NOW() WHERE id = $1`, deviceID) -} - -// For failures - SetOffline returns true if state changed (online->offline) -becameOffline := store.SetOffline(ip, errorMessage) -if becameOffline { - db.Exec(`UPDATE devices SET status = 'offline', last_seen = NOW() WHERE id = $1`, deviceID) -} -``` - -#### Periodic Batch Sync +#### Coarse Last-Available Persistence -For crash recovery, a periodic batch sync runs every ~10 minutes: -- Syncs `last_seen` timestamps to database -- Ensures `status` column matches memory state -- Single bulk query instead of per-device writes +`last_seen` is the one polling-derived value retained in `devices`. It is deliberately +low-frequency: an hourly wall-clock task takes the in-memory last-seen snapshot and +performs one batch update for devices seen recently. Rows whose durable timestamp was +updated within roughly the previous hour are skipped. -```go -// Every 20 poll cycles (~10 min at 30s interval) -if cleanupCounter%20 == 0 { - p.batchSyncToDB() -} -``` +This keeps a useful "last available" marker across restarts without turning PostgreSQL +or its WAL into a 30-second telemetry sink. The live API may return a newer in-memory +`last_seen` while the process is running without persisting it immediately. -This pattern applies uniformly to all device types: Wave, LTU, airMAX AC/M, AirFiber. +This rule applies uniformly to Wave, LTU, airMAX AC/M, and AirFiber. ### Device Identification @@ -887,7 +877,7 @@ To prevent this, the poller enforces a **canonical MAC** per poll job: - Other observed MACs are informational and are included in logs/context. - If the device returns a MAC candidate set and **none match the expected/job MAC**, wavecontrol treats this as a data quality issue: - Do **not** apply the stats/peers update to the expected device - - Mark the expected device `status=unknown`, `status_reason=mac_mismatch` + - Mark the expected device unknown with reason `mac_mismatch` **in memory/WebSocket state only** - Do **not** advance `last_seen` for the expected device (we did not see it) - Persist `device_identity_mismatches` with expected MAC, observed MAC candidates, observed IP, source, timestamp, and last error - Broadcast a WebSocket patch containing `identity_mismatch` context for the selected detail pane @@ -900,12 +890,12 @@ A MAC mismatch is never auto-healed. AP or directly managed STA replacement must The operator may resolve a confirmed AP or managed STA swap through `POST /api/wavecontrol/devices/{id}/learn-mac`. The server must: 1. Require editor/administrator permission. -2. Verify the device is still in `status_reason=mac_mismatch`. -3. Verify a persisted identity-mismatch row exists for the device. +2. Verify a persisted `device_identity_mismatches` row exists for the device. +3. Verify its expected MAC still matches the current inventory MAC. 4. Verify the requested new MAC is one of the observed MAC candidates. 5. Verify the observed IP still matches the device row IP. 6. Reject the request if the observed MAC already exists on another device row. -7. Update the device MAC, clear mismatch state, remove stale in-memory stats, write changelog, broadcast a WebSocket patch, and queue a refresh. For AP rows only, rewrite child `parent_mac` references from the old AP MAC to the new AP MAC; for STA rows, keep the AP association unchanged. +7. Update the device MAC, clear the mismatch record and stale in-memory stats, write changelog, broadcast a WebSocket patch, and queue a refresh. For AP rows only, rewrite child `parent_mac` references from the old AP MAC to the new AP MAC; for STA rows, keep the AP association unchanged. #### Identification Flow From cfa79d443437acaef653594cd468c61783733cd8 Mon Sep 17 00:00:00 2001 From: Chris Cappuccio Date: Fri, 18 Sep 2026 18:23:33 -0700 Subject: [PATCH 08/10] Persist exact coarse last-seen timestamps --- internal/poller/poller_core.go | 36 +++++++++++++++------------------- 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/internal/poller/poller_core.go b/internal/poller/poller_core.go index 316da33..f87bab0 100644 --- a/internal/poller/poller_core.go +++ b/internal/poller/poller_core.go @@ -12,7 +12,6 @@ import ( "sync" "time" - "github.com/lib/pq" "github.com/yellowman/wavecontrol/internal/secrets" "github.com/yellowman/wavecontrol/internal/stats" "github.com/yellowman/wavecontrol/internal/udebug" @@ -480,36 +479,33 @@ func (p *Poller) syncLastSeenToDB() { return } - now := time.Now() - freshWindow := 5 * time.Minute - if interval := p.cfgSnapshot().interval * 3; interval > freshWindow { - freshWindow = interval - } - - recentMACs := make([]string, 0, len(lastSeenBatch)) + values := make([]string, 0, len(lastSeenBatch)) + args := make([]any, 0, len(lastSeenBatch)*2) for mac, lastSeen := range lastSeenBatch { + mac = strings.ToLower(strings.TrimSpace(mac)) if mac == "" || lastSeen.IsZero() { continue } - if now.Sub(lastSeen) <= freshWindow { - recentMACs = append(recentMACs, mac) - } + args = append(args, mac, lastSeen) + values = append(values, fmt.Sprintf("($%d::text, $%d::timestamp)", len(args)-1, len(args))) } - if len(recentMACs) == 0 { + if len(values) == 0 { return } - _, err := dbExecCtx(p.db, dbCtxForOp("sync_last_seen"), ` - UPDATE devices - SET last_seen = NOW() - WHERE mac = ANY($1) - AND (last_seen IS NULL OR last_seen < NOW() - INTERVAL '55 minutes') - `, pq.Array(recentMACs)) - if err != nil { + query := ` + UPDATE devices AS d + SET last_seen = v.last_seen + FROM (VALUES ` + strings.Join(values, ",") + `) AS v(mac, last_seen) + WHERE lower(d.mac) = v.mac + AND v.last_seen > COALESCE(d.last_seen, TIMESTAMP 'epoch') + AND (d.last_seen IS NULL OR d.last_seen < NOW() - INTERVAL '55 minutes') + ` + if _, err := dbExecCtx(p.db, dbCtxForOp("sync_last_seen"), query, args...); err != nil { p.logDebug("syncLastSeenToDB: update failed: %v", err) return } - p.logDebug("syncLastSeenToDB: refreshed %d recently available devices", len(recentMACs)) + p.logDebug("syncLastSeenToDB: checkpointed %d in-memory last-seen values", len(values)) } // cleanCircuitBreakers removes old entries From 0af21042f896a4d982143995018d20d8cd2f35a8 Mon Sep 17 00:00:00 2001 From: Chris Cappuccio Date: Fri, 18 Sep 2026 18:23:55 -0700 Subject: [PATCH 09/10] Clarify coarse last-seen semantics --- SPEC.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/SPEC.md b/SPEC.md index 869c86e..39d7ffc 100644 --- a/SPEC.md +++ b/SPEC.md @@ -844,8 +844,10 @@ compatibility. Polling code must not read or write them. `last_seen` is the one polling-derived value retained in `devices`. It is deliberately low-frequency: an hourly wall-clock task takes the in-memory last-seen snapshot and -performs one batch update for devices seen recently. Rows whose durable timestamp was -updated within roughly the previous hour are skipped. +performs one batch update using each device's actual in-memory timestamp. Rows whose +durable timestamp was updated within roughly the previous hour are skipped; an offline +device can therefore eventually persist its exact final availability time without any +per-poll writes. This keeps a useful "last available" marker across restarts without turning PostgreSQL or its WAL into a 30-second telemetry sink. The live API may return a newer in-memory From f2b5383d43dea1faa0446cb2b089065b2c6deb0e Mon Sep 17 00:00:00 2001 From: Chris Cappuccio Date: Fri, 18 Sep 2026 18:32:33 -0700 Subject: [PATCH 10/10] Fix runtime-state review findings --- SPEC.md | 7 ++++ cmd/server/api.go | 27 +++++++------- internal/poller/children.go | 4 +- internal/poller/poller_core.go | 53 ++++++++++++++++++--------- internal/stats/store.go | 31 +++++++--------- internal/stats/store_identity_test.go | 36 ++++++++++++++++++ 6 files changed, 108 insertions(+), 50 deletions(-) diff --git a/SPEC.md b/SPEC.md index 39d7ffc..253248c 100644 --- a/SPEC.md +++ b/SPEC.md @@ -855,6 +855,13 @@ or its WAL into a 30-second telemetry sink. The live API may return a newer in-m This rule applies uniformly to Wave, LTU, airMAX AC/M, and AirFiber. +**GPS persistence:** poller-learned AirMAX GPS coordinates are fill-once inventory. +Once durable `gps_lat/gps_lon` are populated, ordinary polling does not rewrite +them for GPS jitter or physical relocation. While the process is running, the API +overlays current live GPS from memory. After a restart, the durable coordinates +are shown until a successful poll provides the live overlay. Durable location +changes should be explicit inventory actions rather than telemetry side effects. + ### Device Identification **MAC address is the authoritative unique identifier for all devices.** diff --git a/cmd/server/api.go b/cmd/server/api.go index 895dfb4..45f9841 100644 --- a/cmd/server/api.go +++ b/cmd/server/api.go @@ -6093,13 +6093,14 @@ func (a *API) DryRunOperation(w http.ResponseWriter, r *http.Request) { Compatible: true, } - // Get device info from database - var ip, hostname, product, firmware, flavor, status string + // Get durable inventory only. Runtime eligibility comes exclusively + // from the in-memory stats store. + var ip, mac, hostname, product, firmware, flavor string err := a.DB.QueryRow(` - SELECT host(ip_address), COALESCE(hostname, ''), COALESCE(product, ''), - COALESCE(firmware, ''), COALESCE(flavor, ''), COALESCE(status, 'unknown') + SELECT host(ip_address), lower(mac), COALESCE(hostname, ''), COALESCE(product, ''), + COALESCE(firmware, ''), COALESCE(flavor, '') FROM devices WHERE id = $1 - `, deviceID).Scan(&ip, &hostname, &product, &firmware, &flavor, &status) + `, deviceID).Scan(&ip, &mac, &hostname, &product, &firmware, &flavor) if err != nil { result.Compatible = false @@ -6113,15 +6114,13 @@ func (a *API) DryRunOperation(w http.ResponseWriter, r *http.Request) { result.CurrentVer = firmware result.Flavor = flavor - // Check device is online using stats store (real-time) or database status (fallback) - online := false - if stats := a.Stats.Get(ip); stats != nil { - online = stats.Online - } else { - online = status == "online" - } - - if !online { + // No live sample means the device is not eligible yet; never revive + // stale status from the inventory row after a restart. + live := a.Stats.GetByMAC(mac) + if live == nil { + result.Compatible = false + result.Issues = append(result.Issues, "Device has no live status") + } else if !live.Online { result.Compatible = false result.Issues = append(result.Issues, "Device is offline") } diff --git a/internal/poller/children.go b/internal/poller/children.go index 58e3290..a76d52c 100644 --- a/internal/poller/children.go +++ b/internal/poller/children.go @@ -499,8 +499,8 @@ func (p *Poller) markMissingSTAsOffline(apID int64, associatedMACs []string) { continue } p.store.BindIdentityByMAC(mac, ip, int(id), siteID) - p.store.SetStatusByMAC(mac, ip, stats.StatusOffline, "not_associated", "", false) - if p.wsHub != nil { + _, changed := p.store.SetStatusByMACChanged(mac, ip, stats.StatusOffline, "not_associated", "", false) + if changed && p.wsHub != nil { p.wsHub.BroadcastDeviceUpdate(int(id), ip, map[string]any{ "id": id, "status": "offline", "db_status": "offline", "status_reason": "not_associated", }) diff --git a/internal/poller/poller_core.go b/internal/poller/poller_core.go index f87bab0..89b8044 100644 --- a/internal/poller/poller_core.go +++ b/internal/poller/poller_core.go @@ -479,33 +479,52 @@ func (p *Poller) syncLastSeenToDB() { return } - values := make([]string, 0, len(lastSeenBatch)) - args := make([]any, 0, len(lastSeenBatch)*2) + type checkpoint struct { + mac string + lastSeen time.Time + } + checkpoints := make([]checkpoint, 0, len(lastSeenBatch)) for mac, lastSeen := range lastSeenBatch { mac = strings.ToLower(strings.TrimSpace(mac)) if mac == "" || lastSeen.IsZero() { continue } - args = append(args, mac, lastSeen) - values = append(values, fmt.Sprintf("($%d::text, $%d::timestamp)", len(args)-1, len(args))) + checkpoints = append(checkpoints, checkpoint{mac: mac, lastSeen: lastSeen}) } - if len(values) == 0 { + if len(checkpoints) == 0 { return } - query := ` - UPDATE devices AS d - SET last_seen = v.last_seen - FROM (VALUES ` + strings.Join(values, ",") + `) AS v(mac, last_seen) - WHERE lower(d.mac) = v.mac - AND v.last_seen > COALESCE(d.last_seen, TIMESTAMP 'epoch') - AND (d.last_seen IS NULL OR d.last_seen < NOW() - INTERVAL '55 minutes') - ` - if _, err := dbExecCtx(p.db, dbCtxForOp("sync_last_seen"), query, args...); err != nil { - p.logDebug("syncLastSeenToDB: update failed: %v", err) - return + // Two bind parameters per row. Keep statements comfortably below + // PostgreSQL's 65535-parameter limit. + const chunkSize = 2000 + for start := 0; start < len(checkpoints); start += chunkSize { + end := start + chunkSize + if end > len(checkpoints) { + end = len(checkpoints) + } + + values := make([]string, 0, end-start) + args := make([]any, 0, (end-start)*2) + for _, cp := range checkpoints[start:end] { + args = append(args, cp.mac, cp.lastSeen) + values = append(values, fmt.Sprintf("($%d::text, $%d::timestamptz)", len(args)-1, len(args))) + } + + query := ` + UPDATE devices AS d + SET last_seen = v.last_seen + FROM (VALUES ` + strings.Join(values, ",") + `) AS v(mac, last_seen) + WHERE lower(d.mac) = v.mac + AND v.last_seen > COALESCE(d.last_seen, TIMESTAMP 'epoch') + AND (d.last_seen IS NULL OR d.last_seen < NOW() - INTERVAL '55 minutes') + ` + if _, err := dbExecCtx(p.db, dbCtxForOp("sync_last_seen"), query, args...); err != nil { + p.logDebug("syncLastSeenToDB: chunk %d-%d failed: %v", start, end, err) + return + } } - p.logDebug("syncLastSeenToDB: checkpointed %d in-memory last-seen values", len(values)) + p.logDebug("syncLastSeenToDB: checkpointed %d in-memory last-seen values", len(checkpoints)) } // cleanCircuitBreakers removes old entries diff --git a/internal/stats/store.go b/internal/stats/store.go index de4dfa4..5432018 100644 --- a/internal/stats/store.go +++ b/internal/stats/store.go @@ -982,6 +982,13 @@ func (s *Store) BindIdentityByMAC(mac, ip string, deviceID, siteID int) { } func (s *Store) SetStatusByMAC(mac, ip string, status DeviceStatus, reason, errMsg string, markSeen bool) bool { + leftOnline, _ := s.SetStatusByMACChanged(mac, ip, status, reason, errMsg, markSeen) + return leftOnline +} + +// SetStatusByMACChanged updates live status and reports both whether the device +// left online state and whether the externally visible status/reason changed. +func (s *Store) SetStatusByMACChanged(mac, ip string, status DeviceStatus, reason, errMsg string, markSeen bool) (bool, bool) { s.mu.Lock() defer s.mu.Unlock() @@ -1003,11 +1010,12 @@ func (s *Store) SetStatusByMAC(mac, ip string, status DeviceStatus, reason, errM } } if key == "" { - return false + return false, false } ds, ok := s.devices[key] - if !ok || ds == nil { + created := !ok || ds == nil + if created { ds = &DeviceStats{} s.devices[key] = ds } @@ -1046,6 +1054,9 @@ func (s *Store) SetStatusByMAC(mac, ip string, status DeviceStatus, reason, errM } } + prevReason := ds.StatusReason + changed := created || prevStatus != status || prevReason != reason + ds.Status = status ds.DBStatus = string(ds.Status) ds.StatusReason = reason @@ -1059,7 +1070,7 @@ func (s *Store) SetStatusByMAC(mac, ip string, status DeviceStatus, reason, errM ds.LastSeen = time.Now() } - return prevStatus == StatusOnline && status != StatusOnline + return prevStatus == StatusOnline && status != StatusOnline, changed } // SetStatus sets a device's status using an IP address (legacy helper). @@ -1387,20 +1398,6 @@ func (s *Store) LastSeenBatch() map[string]time.Time { return result } -// OnlineStatusBatch returns MAC -> Online for all devices (for DB sync) -func (s *Store) OnlineStatusBatch() map[string]bool { - s.mu.RLock() - defer s.mu.RUnlock() - - result := make(map[string]bool, len(s.devices)) - for _, stats := range s.devices { - if stats.MAC != "" { - result[stats.MAC] = stats.Online - } - } - return result -} - // CleanStale removes STAs that haven't been seen recently // Only removes child devices (STAs with ParentIP set), never APs func (s *Store) CleanStale() int { diff --git a/internal/stats/store_identity_test.go b/internal/stats/store_identity_test.go index cbf34e5..09d95bc 100644 --- a/internal/stats/store_identity_test.go +++ b/internal/stats/store_identity_test.go @@ -59,3 +59,39 @@ func TestBindIdentityByMACCanClearSiteIdentity(t *testing.T) { t.Fatalf("SiteID = %d, want 0 after explicit clear", got.SiteID) } } + + +func TestSetStatusByMACChangedReportsVisibleStateChanges(t *testing.T) { + store := NewStore() + const mac = "28:70:4e:e1:e8:b5" + const ip = "172.20.66.7" + + store.BindIdentityByMAC(mac, ip, 89, 12) + + leftOnline, changed := store.SetStatusByMACChanged(mac, ip, StatusOffline, "not_associated", "", false) + if leftOnline { + t.Fatal("initial unknown->offline transition must not report leftOnline") + } + if !changed { + t.Fatal("initial offline/not_associated state must report changed") + } + + leftOnline, changed = store.SetStatusByMACChanged(mac, ip, StatusOffline, "not_associated", "", false) + if leftOnline { + t.Fatal("repeated offline state must not report leftOnline") + } + if changed { + t.Fatal("identical repeated status/reason must not report changed") + } + + _, changed = store.SetStatusByMACChanged(mac, ip, StatusOffline, "parent_offline", "", false) + if !changed { + t.Fatal("reason-only change must report changed") + } + + store.SetStatusByMAC(mac, ip, StatusOnline, "", "", true) + leftOnline, changed = store.SetStatusByMACChanged(mac, ip, StatusOffline, "not_associated", "", false) + if !leftOnline || !changed { + t.Fatalf("online->offline = leftOnline %v, changed %v; want true,true", leftOnline, changed) + } +}