station: Surface signal, traffic and connection time in API and UI

Hostapd already parsed signal strength and rx/tx counters but the
station -> ConnectedDevice conversion threw them away. Add signalDbm,
rxBytes, txBytes and connectedAt to the OpenAPI schema and the
ConnectedDevice model, and centralise the conversion in
station.ToConnectedDevice so handlers, the periodic refresh and the
event callbacks all serialise the same shape.

Two follow-on bugs surfaced while wiring this up:

- The hostapd backend only stored station entries on first contact.
  Subsequent polls were dropped, so signal and byte counters never
  refreshed. Reconcile updates in checkStationChanges.
- ConnectedAt was reset to time.Now() on every conversion. Track
  FirstSeen on HostapdStation when the station joins, and preserve
  the timestamp across periodic refreshes in app.go so the UI's
  "connected since" badge is stable.

Frontend gains a metrics row on each device card with signal bars,
total traffic and a live duration. Falls back gracefully when a
backend (DHCP, ARP) doesn't expose these fields.
This commit is contained in:
nemunaire 2026-05-01 22:26:43 +08:00
commit 950f73371c
8 changed files with 231 additions and 37 deletions

View file

@ -177,6 +177,8 @@ func (b *Backend) checkStationChanges() error {
return err
}
now := time.Now()
b.mu.Lock()
defer b.mu.Unlock()
@ -186,17 +188,24 @@ func (b *Backend) checkStationChanges() error {
currentMACs[mac] = true
}
// Check for new stations
// Reconcile current poll with cached state
for mac, station := range currentStations {
if _, exists := b.stations[mac]; !exists {
// New station connected
b.stations[mac] = station
if cb := b.callbacks.OnStationConnected; cb != nil {
st := b.convertStation(mac, station)
backend.SafeGo("OnStationConnected", func() { cb(st) })
}
log.Printf("Station connected: %s", mac)
if existing, exists := b.stations[mac]; exists {
// Refresh signal/byte counters in place — without this, GetStations
// would keep returning the values from the very first poll.
existing.Signal = station.Signal
existing.RxBytes = station.RxBytes
existing.TxBytes = station.TxBytes
continue
}
// New station connected
station.FirstSeen = now
b.stations[mac] = station
if cb := b.callbacks.OnStationConnected; cb != nil {
st := b.convertStation(mac, station)
backend.SafeGo("OnStationConnected", func() { cb(st) })
}
log.Printf("Station connected: %s", mac)
}
// Check for removed stations
@ -223,6 +232,12 @@ func (b *Backend) loadStations() error {
return err
}
now := time.Now()
for _, st := range stations {
// We can't tell when these stations actually connected; treat the
// daemon-start time as a lower bound rather than leaving it zero.
st.FirstSeen = now
}
b.stations = stations
log.Printf("Loaded %d initial stations from hostapd", len(b.stations))
return nil
@ -303,6 +318,11 @@ func (b *Backend) convertStation(mac string, hs *HostapdStation) backend.Station
ip := b.ipByMAC[mac]
hostname := b.hostnameByMAC[mac]
connectedAt := hs.FirstSeen
if connectedAt.IsZero() {
connectedAt = time.Now()
}
return backend.Station{
MAC: mac,
IP: ip,
@ -311,7 +331,7 @@ func (b *Backend) convertStation(mac string, hs *HostapdStation) backend.Station
Signal: hs.Signal,
RxBytes: hs.RxBytes,
TxBytes: hs.TxBytes,
ConnectedAt: time.Now(), // We don't have exact connection time
ConnectedAt: connectedAt,
}
}