diff --git a/cmd/repeater/static/app.js b/cmd/repeater/static/app.js index 7d8a1b4..9984f88 100644 --- a/cmd/repeater/static/app.js +++ b/cmd/repeater/static/app.js @@ -405,14 +405,17 @@ function displayDevices(devices) { ? device.name : 'Sans nom'; + const metrics = buildDeviceMetrics(device); + deviceCard.innerHTML = ` ${getDeviceIcon(device.type)}
${escapeHtml(displayName)}
${escapeHtml(formatDeviceType(device.type))}
${escapeHtml(device.ip || '—')}
-
${escapeHtml(device.mac)}
+
${escapeHtml(device.mac)}
+ ${metrics ? `
${metrics}
` : ''} `; devicesList.appendChild(deviceCard); @@ -430,6 +433,78 @@ function formatDeviceType(type) { return labels[type] || labels.unknown; } +function buildDeviceMetrics(device) { + const parts = []; + + if (typeof device.signalDbm === 'number' && device.signalDbm < 0) { + const level = signalLevelFromDbm(device.signalDbm); + parts.push(` + + ${generateSignalBars(level)} + ${device.signalDbm} dBm + + `); + } + + const total = (device.rxBytes || 0) + (device.txBytes || 0); + if (total > 0) { + parts.push(` + + Trafic + ${formatBytes(total)} + + `); + } + + if (device.connectedAt) { + const since = new Date(device.connectedAt); + if (!isNaN(since.getTime())) { + const elapsed = Math.max(0, Math.floor((Date.now() - since.getTime()) / 1000)); + parts.push(` + + Depuis + ${formatDuration(elapsed)} + + `); + } + } + + return parts.join(''); +} + +function signalLevelFromDbm(dbm) { + // dBm to 1-4 bars: -50+ excellent, -60 good, -70 fair, <=-80 weak + if (dbm >= -55) return 4; + if (dbm >= -65) return 3; + if (dbm >= -75) return 2; + return 1; +} + +function formatBytes(bytes) { + if (!bytes || bytes < 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let i = 0; + let value = bytes; + while (value >= 1024 && i < units.length - 1) { + value /= 1024; + i++; + } + const precision = value >= 100 || i === 0 ? 0 : 1; + return `${value.toFixed(precision)} ${units[i]}`; +} + +function formatDuration(seconds) { + if (seconds < 60) return `${seconds}s`; + const m = Math.floor(seconds / 60); + if (m < 60) return `${m} min`; + const h = Math.floor(m / 60); + const remM = m % 60; + if (h < 24) return remM > 0 ? `${h}h${remM.toString().padStart(2, '0')}` : `${h}h`; + const d = Math.floor(h / 24); + const remH = h % 24; + return remH > 0 ? `${d}j ${remH}h` : `${d}j`; +} + function addLogEntry(log) { const logContainer = document.getElementById('logContainer'); diff --git a/cmd/repeater/static/style.css b/cmd/repeater/static/style.css index 9b12b92..f233a17 100644 --- a/cmd/repeater/static/style.css +++ b/cmd/repeater/static/style.css @@ -562,6 +562,49 @@ body { font-variant-numeric: tabular-nums; } +.device-mac { + font-size: 0.6875rem; + color: var(--text-tertiary); + letter-spacing: 0.02em; + margin-top: 0.125rem; +} + +.device-metrics { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 0.5rem 0.75rem; + margin-top: 0.625rem; + padding-top: 0.625rem; + border-top: 1px solid var(--border-light); +} + +.device-metric { + display: inline-flex; + align-items: center; + gap: 0.3125rem; + font-size: 0.6875rem; + color: var(--text-secondary); + font-variant-numeric: tabular-nums; +} + +.device-metric-label { + color: var(--text-tertiary); + text-transform: uppercase; + letter-spacing: 0.05em; + font-size: 0.625rem; +} + +.device-metric-value { + color: var(--text); + font-weight: 500; +} + +.device-metric .signal-bars { + transform: scale(0.85); + transform-origin: left center; +} + .device-placeholder { grid-column: 1 / -1; text-align: center; diff --git a/internal/app/app.go b/internal/app/app.go index 4efcf3d..8b89b65 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -244,6 +244,20 @@ func (a *App) periodicDeviceUpdate() { } a.StatusMutex.Lock() + // Preserve ConnectedAt across refreshes so the UI's "connected since" + // timestamp doesn't reset every poll. Backends that don't track a + // real connection time return time.Now() each call. + previous := make(map[string]time.Time, len(a.Status.ConnectedDevices)) + for _, d := range a.Status.ConnectedDevices { + if !d.ConnectedAt.IsZero() { + previous[d.MAC] = d.ConnectedAt + } + } + for i := range devices { + if t, ok := previous[devices[i].MAC]; ok { + devices[i].ConnectedAt = t + } + } a.Status.ConnectedDevices = devices a.Status.ConnectedCount = len(devices) a.StatusMutex.Unlock() @@ -255,13 +269,7 @@ func (a *App) handleStationConnected(st backend.Station) { a.StatusMutex.Lock() defer a.StatusMutex.Unlock() - // Convert backend.Station to models.ConnectedDevice - device := models.ConnectedDevice{ - Name: st.Hostname, - Type: st.Type, - MAC: st.MAC, - IP: st.IP, - } + device := station.ToConnectedDevice(st) // Check if device already exists found := false @@ -302,15 +310,16 @@ func (a *App) handleStationUpdated(st backend.Station) { a.StatusMutex.Lock() defer a.StatusMutex.Unlock() - // Update existing device + // Update existing device. Preserve the original ConnectedAt so the + // device card doesn't reset its "connected since" badge each time the + // IP or signal updates. for i, d := range a.Status.ConnectedDevices { if d.MAC == st.MAC { - a.Status.ConnectedDevices[i] = models.ConnectedDevice{ - Name: st.Hostname, - Type: st.Type, - MAC: st.MAC, - IP: st.IP, + updated := station.ToConnectedDevice(st) + if !d.ConnectedAt.IsZero() { + updated.ConnectedAt = d.ConnectedAt } + a.Status.ConnectedDevices[i] = updated break } } diff --git a/internal/models/models.go b/internal/models/models.go index 64d5c79..138aeb1 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -13,10 +13,15 @@ type WiFiNetwork struct { // ConnectedDevice represents a device connected to the hotspot type ConnectedDevice struct { - Name string `json:"name"` - Type string `json:"type"` - MAC string `json:"mac"` - IP string `json:"ip"` + Name string `json:"name"` + Type string `json:"type"` + MAC string `json:"mac"` + IP string `json:"ip"` + Vendor string `json:"vendor,omitempty"` + SignalDbm int32 `json:"signalDbm,omitempty"` + RxBytes uint64 `json:"rxBytes,omitempty"` + TxBytes uint64 `json:"txBytes,omitempty"` + ConnectedAt time.Time `json:"connectedAt,omitzero"` } // HotspotConfig represents hotspot configuration diff --git a/internal/station/hostapd/backend.go b/internal/station/hostapd/backend.go index 0aba910..13cb11f 100644 --- a/internal/station/hostapd/backend.go +++ b/internal/station/hostapd/backend.go @@ -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, } } diff --git a/internal/station/hostapd/types.go b/internal/station/hostapd/types.go index 2521c1f..6f650a5 100644 --- a/internal/station/hostapd/types.go +++ b/internal/station/hostapd/types.go @@ -1,10 +1,13 @@ package hostapd +import "time" + // HostapdStation represents station properties from hostapd_cli type HostapdStation struct { RxPackets uint64 TxPackets uint64 RxBytes uint64 TxBytes uint64 - Signal int32 // Signal in dBm + Signal int32 // Signal in dBm + FirstSeen time.Time // When the station was first observed (best effort) } diff --git a/internal/station/station.go b/internal/station/station.go index e6acaf1..8e3a337 100644 --- a/internal/station/station.go +++ b/internal/station/station.go @@ -51,20 +51,35 @@ func GetStations() ([]models.ConnectedDevice, error) { return nil, err } - // Convert backend.Station to models.ConnectedDevice devices := make([]models.ConnectedDevice, len(stations)) for i, s := range stations { - devices[i] = models.ConnectedDevice{ - Name: s.Hostname, - Type: s.Type, - MAC: s.MAC, - IP: s.IP, - } + devices[i] = toConnectedDevice(s) } return devices, nil } +// toConnectedDevice converts a backend Station to the API model. Centralised +// so handlers, the periodic refresh and the event callbacks all serialise the +// same shape. +func toConnectedDevice(s backend.Station) models.ConnectedDevice { + return models.ConnectedDevice{ + Name: s.Hostname, + Type: s.Type, + MAC: s.MAC, + IP: s.IP, + SignalDbm: s.Signal, + RxBytes: s.RxBytes, + TxBytes: s.TxBytes, + ConnectedAt: s.ConnectedAt, + } +} + +// ToConnectedDevice exposes the conversion for callers in other packages. +func ToConnectedDevice(s backend.Station) models.ConnectedDevice { + return toConnectedDevice(s) +} + // StartEventMonitoring starts monitoring for station events func StartEventMonitoring(callbacks backend.EventCallbacks) error { mu.RLock() diff --git a/openapi.yaml b/openapi.yaml index 4357d09..f3132c4 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -418,7 +418,7 @@ components: properties: name: type: string - description: Device hostname + description: Device hostname (may be empty when DHCP did not provide one) example: "iPhone-12" type: type: string @@ -440,6 +440,30 @@ components: description: Assigned IP address pattern: '^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$' example: "192.168.1.100" + vendor: + type: string + description: Hardware vendor inferred from the MAC OUI prefix (empty when unknown) + example: "Apple" + signalDbm: + type: integer + format: int32 + description: Wi-Fi signal strength in dBm (0 when unavailable, e.g. for non-hostapd backends) + example: -54 + rxBytes: + type: integer + format: int64 + description: Total bytes received from the device since connection (0 when unavailable) + example: 1048576 + txBytes: + type: integer + format: int64 + description: Total bytes sent to the device since connection (0 when unavailable) + example: 524288 + connectedAt: + type: string + format: date-time + description: Timestamp when the station first appeared (best effort) + example: "2026-05-01T08:30:00Z" required: - name - type