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:
parent
a758c331c0
commit
950f73371c
8 changed files with 231 additions and 37 deletions
|
|
@ -405,14 +405,17 @@ function displayDevices(devices) {
|
||||||
? device.name
|
? device.name
|
||||||
: 'Sans nom';
|
: 'Sans nom';
|
||||||
|
|
||||||
|
const metrics = buildDeviceMetrics(device);
|
||||||
|
|
||||||
deviceCard.innerHTML = `
|
deviceCard.innerHTML = `
|
||||||
${getDeviceIcon(device.type)}
|
${getDeviceIcon(device.type)}
|
||||||
<div class="device-name">${escapeHtml(displayName)}</div>
|
<div class="device-name">${escapeHtml(displayName)}</div>
|
||||||
<div class="device-type">${escapeHtml(formatDeviceType(device.type))}</div>
|
<div class="device-type">${escapeHtml(formatDeviceType(device.type))}</div>
|
||||||
<div class="device-info">
|
<div class="device-info">
|
||||||
<div>${escapeHtml(device.ip || '—')}</div>
|
<div>${escapeHtml(device.ip || '—')}</div>
|
||||||
<div style="font-size: 0.75rem;">${escapeHtml(device.mac)}</div>
|
<div class="device-mac">${escapeHtml(device.mac)}</div>
|
||||||
</div>
|
</div>
|
||||||
|
${metrics ? `<div class="device-metrics">${metrics}</div>` : ''}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
devicesList.appendChild(deviceCard);
|
devicesList.appendChild(deviceCard);
|
||||||
|
|
@ -430,6 +433,78 @@ function formatDeviceType(type) {
|
||||||
return labels[type] || labels.unknown;
|
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(`
|
||||||
|
<span class="device-metric" title="${device.signalDbm} dBm">
|
||||||
|
${generateSignalBars(level)}
|
||||||
|
<span class="device-metric-value">${device.signalDbm} dBm</span>
|
||||||
|
</span>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = (device.rxBytes || 0) + (device.txBytes || 0);
|
||||||
|
if (total > 0) {
|
||||||
|
parts.push(`
|
||||||
|
<span class="device-metric" title="Reçu ${formatBytes(device.rxBytes || 0)} / Émis ${formatBytes(device.txBytes || 0)}">
|
||||||
|
<span class="device-metric-label">Trafic</span>
|
||||||
|
<span class="device-metric-value">${formatBytes(total)}</span>
|
||||||
|
</span>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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(`
|
||||||
|
<span class="device-metric" title="Connecté depuis ${since.toLocaleTimeString('fr-FR')}">
|
||||||
|
<span class="device-metric-label">Depuis</span>
|
||||||
|
<span class="device-metric-value">${formatDuration(elapsed)}</span>
|
||||||
|
</span>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
function addLogEntry(log) {
|
||||||
const logContainer = document.getElementById('logContainer');
|
const logContainer = document.getElementById('logContainer');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -562,6 +562,49 @@ body {
|
||||||
font-variant-numeric: tabular-nums;
|
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 {
|
.device-placeholder {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
|
||||||
|
|
@ -244,6 +244,20 @@ func (a *App) periodicDeviceUpdate() {
|
||||||
}
|
}
|
||||||
|
|
||||||
a.StatusMutex.Lock()
|
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.ConnectedDevices = devices
|
||||||
a.Status.ConnectedCount = len(devices)
|
a.Status.ConnectedCount = len(devices)
|
||||||
a.StatusMutex.Unlock()
|
a.StatusMutex.Unlock()
|
||||||
|
|
@ -255,13 +269,7 @@ func (a *App) handleStationConnected(st backend.Station) {
|
||||||
a.StatusMutex.Lock()
|
a.StatusMutex.Lock()
|
||||||
defer a.StatusMutex.Unlock()
|
defer a.StatusMutex.Unlock()
|
||||||
|
|
||||||
// Convert backend.Station to models.ConnectedDevice
|
device := station.ToConnectedDevice(st)
|
||||||
device := models.ConnectedDevice{
|
|
||||||
Name: st.Hostname,
|
|
||||||
Type: st.Type,
|
|
||||||
MAC: st.MAC,
|
|
||||||
IP: st.IP,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if device already exists
|
// Check if device already exists
|
||||||
found := false
|
found := false
|
||||||
|
|
@ -302,15 +310,16 @@ func (a *App) handleStationUpdated(st backend.Station) {
|
||||||
a.StatusMutex.Lock()
|
a.StatusMutex.Lock()
|
||||||
defer a.StatusMutex.Unlock()
|
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 {
|
for i, d := range a.Status.ConnectedDevices {
|
||||||
if d.MAC == st.MAC {
|
if d.MAC == st.MAC {
|
||||||
a.Status.ConnectedDevices[i] = models.ConnectedDevice{
|
updated := station.ToConnectedDevice(st)
|
||||||
Name: st.Hostname,
|
if !d.ConnectedAt.IsZero() {
|
||||||
Type: st.Type,
|
updated.ConnectedAt = d.ConnectedAt
|
||||||
MAC: st.MAC,
|
|
||||||
IP: st.IP,
|
|
||||||
}
|
}
|
||||||
|
a.Status.ConnectedDevices[i] = updated
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,10 +13,15 @@ type WiFiNetwork struct {
|
||||||
|
|
||||||
// ConnectedDevice represents a device connected to the hotspot
|
// ConnectedDevice represents a device connected to the hotspot
|
||||||
type ConnectedDevice struct {
|
type ConnectedDevice struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
MAC string `json:"mac"`
|
MAC string `json:"mac"`
|
||||||
IP string `json:"ip"`
|
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
|
// HotspotConfig represents hotspot configuration
|
||||||
|
|
|
||||||
|
|
@ -177,6 +177,8 @@ func (b *Backend) checkStationChanges() error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
b.mu.Lock()
|
b.mu.Lock()
|
||||||
defer b.mu.Unlock()
|
defer b.mu.Unlock()
|
||||||
|
|
||||||
|
|
@ -186,17 +188,24 @@ func (b *Backend) checkStationChanges() error {
|
||||||
currentMACs[mac] = true
|
currentMACs[mac] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for new stations
|
// Reconcile current poll with cached state
|
||||||
for mac, station := range currentStations {
|
for mac, station := range currentStations {
|
||||||
if _, exists := b.stations[mac]; !exists {
|
if existing, exists := b.stations[mac]; exists {
|
||||||
// New station connected
|
// Refresh signal/byte counters in place — without this, GetStations
|
||||||
b.stations[mac] = station
|
// would keep returning the values from the very first poll.
|
||||||
if cb := b.callbacks.OnStationConnected; cb != nil {
|
existing.Signal = station.Signal
|
||||||
st := b.convertStation(mac, station)
|
existing.RxBytes = station.RxBytes
|
||||||
backend.SafeGo("OnStationConnected", func() { cb(st) })
|
existing.TxBytes = station.TxBytes
|
||||||
}
|
continue
|
||||||
log.Printf("Station connected: %s", mac)
|
|
||||||
}
|
}
|
||||||
|
// 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
|
// Check for removed stations
|
||||||
|
|
@ -223,6 +232,12 @@ func (b *Backend) loadStations() error {
|
||||||
return err
|
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
|
b.stations = stations
|
||||||
log.Printf("Loaded %d initial stations from hostapd", len(b.stations))
|
log.Printf("Loaded %d initial stations from hostapd", len(b.stations))
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -303,6 +318,11 @@ func (b *Backend) convertStation(mac string, hs *HostapdStation) backend.Station
|
||||||
ip := b.ipByMAC[mac]
|
ip := b.ipByMAC[mac]
|
||||||
hostname := b.hostnameByMAC[mac]
|
hostname := b.hostnameByMAC[mac]
|
||||||
|
|
||||||
|
connectedAt := hs.FirstSeen
|
||||||
|
if connectedAt.IsZero() {
|
||||||
|
connectedAt = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
return backend.Station{
|
return backend.Station{
|
||||||
MAC: mac,
|
MAC: mac,
|
||||||
IP: ip,
|
IP: ip,
|
||||||
|
|
@ -311,7 +331,7 @@ func (b *Backend) convertStation(mac string, hs *HostapdStation) backend.Station
|
||||||
Signal: hs.Signal,
|
Signal: hs.Signal,
|
||||||
RxBytes: hs.RxBytes,
|
RxBytes: hs.RxBytes,
|
||||||
TxBytes: hs.TxBytes,
|
TxBytes: hs.TxBytes,
|
||||||
ConnectedAt: time.Now(), // We don't have exact connection time
|
ConnectedAt: connectedAt,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
package hostapd
|
package hostapd
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
// HostapdStation represents station properties from hostapd_cli
|
// HostapdStation represents station properties from hostapd_cli
|
||||||
type HostapdStation struct {
|
type HostapdStation struct {
|
||||||
RxPackets uint64
|
RxPackets uint64
|
||||||
TxPackets uint64
|
TxPackets uint64
|
||||||
RxBytes uint64
|
RxBytes uint64
|
||||||
TxBytes uint64
|
TxBytes uint64
|
||||||
Signal int32 // Signal in dBm
|
Signal int32 // Signal in dBm
|
||||||
|
FirstSeen time.Time // When the station was first observed (best effort)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -51,20 +51,35 @@ func GetStations() ([]models.ConnectedDevice, error) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert backend.Station to models.ConnectedDevice
|
|
||||||
devices := make([]models.ConnectedDevice, len(stations))
|
devices := make([]models.ConnectedDevice, len(stations))
|
||||||
for i, s := range stations {
|
for i, s := range stations {
|
||||||
devices[i] = models.ConnectedDevice{
|
devices[i] = toConnectedDevice(s)
|
||||||
Name: s.Hostname,
|
|
||||||
Type: s.Type,
|
|
||||||
MAC: s.MAC,
|
|
||||||
IP: s.IP,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return devices, nil
|
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
|
// StartEventMonitoring starts monitoring for station events
|
||||||
func StartEventMonitoring(callbacks backend.EventCallbacks) error {
|
func StartEventMonitoring(callbacks backend.EventCallbacks) error {
|
||||||
mu.RLock()
|
mu.RLock()
|
||||||
|
|
|
||||||
26
openapi.yaml
26
openapi.yaml
|
|
@ -418,7 +418,7 @@ components:
|
||||||
properties:
|
properties:
|
||||||
name:
|
name:
|
||||||
type: string
|
type: string
|
||||||
description: Device hostname
|
description: Device hostname (may be empty when DHCP did not provide one)
|
||||||
example: "iPhone-12"
|
example: "iPhone-12"
|
||||||
type:
|
type:
|
||||||
type: string
|
type: string
|
||||||
|
|
@ -440,6 +440,30 @@ components:
|
||||||
description: Assigned IP address
|
description: Assigned IP address
|
||||||
pattern: '^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$'
|
pattern: '^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$'
|
||||||
example: "192.168.1.100"
|
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:
|
required:
|
||||||
- name
|
- name
|
||||||
- type
|
- type
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue