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