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
|
||||
: 'Sans nom';
|
||||
|
||||
const metrics = buildDeviceMetrics(device);
|
||||
|
||||
deviceCard.innerHTML = `
|
||||
${getDeviceIcon(device.type)}
|
||||
<div class="device-name">${escapeHtml(displayName)}</div>
|
||||
<div class="device-type">${escapeHtml(formatDeviceType(device.type))}</div>
|
||||
<div class="device-info">
|
||||
<div>${escapeHtml(device.ip || '—')}</div>
|
||||
<div style="font-size: 0.75rem;">${escapeHtml(device.mac)}</div>
|
||||
<div class="device-mac">${escapeHtml(device.mac)}</div>
|
||||
</div>
|
||||
${metrics ? `<div class="device-metrics">${metrics}</div>` : ''}
|
||||
`;
|
||||
|
||||
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(`
|
||||
<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) {
|
||||
const logContainer = document.getElementById('logContainer');
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue