feat: show recon client identities and associations

This commit is contained in:
2026-08-21 08:40:25 -05:00
parent 21e308386f
commit cd54402892
2 changed files with 70 additions and 10 deletions
@@ -409,6 +409,9 @@ html.dark .recon-row-compare td { background: rgba(25, 118, 210, .18); }
.recon-focus-twin:hover { background: #689f38; }
.recon-focus-detail { display: flex; justify-content: space-between; gap: 10px; padding: 3px 0; border-bottom: 1px solid var(--border); font-size: 13px; }
.recon-focus-detail-label { color: var(--muted); flex: none; }
.recon-focus-client { display: grid; grid-template-columns: 1fr; gap: 2px; padding: 6px 0; border-bottom: 1px solid var(--border); font-size: 12px; }
.recon-focus-client-mac { font-family: Consolas, Menlo, monospace; }
.recon-focus-client-sources { color: var(--muted); }
.recon-sort-arrow { color: var(--muted); font-size: 11px; }
th.recon-sorted { color: var(--primary); }
.recon-per { width: auto; }
@@ -959,7 +959,8 @@ const RECON_AP_COLS = [
{ key: 'band', label: 'Band', render: (a) => a.band || '--' },
{ key: 'channel', label: 'Channel', render: (a) => a.channel == null ? '--' : a.channel },
{ key: 'signal', label: 'Signal', render: (a) => a.signal == null ? '--' : dbmCell(a.signal) },
{ key: 'vendor', label: 'Vendor', render: (a) => a.vendor || '--' },
{ key: 'identity', label: 'Identity', search: (a) => displayIdentity(a.device_identity), render: (a) => displayIdentity(a.device_identity) },
{ key: 'clients', label: 'Clients', search: (a) => a.client_count == null ? (a.clients || []).length : a.client_count, render: (a) => a.client_count == null ? (a.clients || []).length : a.client_count },
{ key: 'encryption', label: 'Encryption', render: (a) => a.encryption || '--' },
{ key: 'first_seen', label: 'First Seen', render: (a) => fmtShortTime(a.first_seen) },
{ key: 'last_seen', label: 'Last Seen', render: (a) => fmtShortTime(a.last_seen) },
@@ -969,9 +970,37 @@ const RECON_CLIENT_COLS = [
{ key: 'mac', label: 'Client MAC', render: (c) => c.mac },
{ key: 'signal', label: 'Signal', render: (c) => c.signal == null ? '--' : c.signal + ' dBm' },
{ key: 'freq', label: 'Frequency', render: (c) => c.freq || '--' },
{ key: 'packets', label: 'Packets', render: (c) => c.packets || 0 }
{ key: 'packets', label: 'Packets', render: (c) => c.packets || 0 },
{ key: 'vendor', label: 'Vendor', search: (c) => displayIdentity(c.vendor), render: (c) => displayIdentity(c.vendor) },
{ key: 'associated_ssid', label: 'Associated SSID', search: (c) => (c.associations || []).map((a) => a.ssid || '').join(', '), render: (c) => associationCell(c.associations) }
];
function displayIdentity(identity) {
if (typeof identity === 'string') return identity || '--';
const value = identity || {};
const manufacturer = value.manufacturer || 'Unknown';
if (value.model) return manufacturer + ' ' + value.model;
if (manufacturer === 'Unknown' && value.oui) return manufacturer + ' (' + value.oui + ')';
return manufacturer;
}
function associationSummary(associations) {
const ssids = [];
(associations || []).forEach((association) => {
const ssid = association && association.ssid;
if (ssid && ssids.indexOf(ssid) === -1) ssids.push(ssid);
});
if (!ssids.length) return '--';
return ssids.length === 1 ? ssids[0] : ssids[0] + ' +' + (ssids.length - 1);
}
function associationCell(associations) {
const summary = associationSummary(associations);
const full = (associations || []).map((a) => a && a.ssid).filter(Boolean)
.filter((ssid, i, all) => all.indexOf(ssid) === i).join(', ');
return h('span', { title: full || summary, text: summary });
}
function reconBandOf(freq) {
if (freq == null) return null;
if (freq >= 2400 && freq < 2500) return '2.4';
@@ -1003,8 +1032,8 @@ function reconBandLabel(band) {
function reconDefaultCols() {
return {
ap: { compare: true, ssid: true, bssid: true, band: true, channel: true, signal: true, vendor: true, encryption: true, first_seen: true, last_seen: true, hidden: true },
client: { mac: true, signal: true, freq: true, packets: true }
ap: { compare: true, ssid: true, bssid: true, band: true, channel: true, signal: true, identity: true, clients: true, encryption: true, first_seen: true, last_seen: true, hidden: true },
client: { mac: true, signal: true, freq: true, packets: true, vendor: true, associated_ssid: true }
};
}
@@ -1012,6 +1041,9 @@ function reconLoadCols() {
try {
const v = JSON.parse(localStorage.getItem('pw_recon_cols'));
if (v && v.ap && v.client) {
const defaults = reconDefaultCols();
v.ap = Object.assign(defaults.ap, v.ap);
v.client = Object.assign(defaults.client, v.client);
if (typeof v.ap.compare !== 'boolean') v.ap.compare = true;
return v;
}
@@ -1022,7 +1054,10 @@ function reconLoadCols() {
function reconFiltered(rows, q, colsArr) {
const ql = (q || '').toLowerCase();
if (!ql) return rows;
return rows.filter((r) => colsArr.some((c) => String(r[c.key] == null ? '' : r[c.key]).toLowerCase().indexOf(ql) !== -1));
return rows.filter((r) => colsArr.some((c) => {
const value = c.search ? c.search(r) : r[c.key];
return String(value == null ? '' : value).toLowerCase().indexOf(ql) !== -1;
}));
}
// ---------------------------------------------------------------------------
@@ -1665,15 +1700,18 @@ function reconPer(key, def) {
}
function reconCmp(a, b, col, dir) {
const numeric = col.key === 'channel' || col.key === 'signal' || col.key === 'freq' || col.key === 'packets' ||
const numeric = col.key === 'channel' || col.key === 'signal' || col.key === 'freq' || col.key === 'packets' || col.key === 'clients' ||
col.key === 'first_seen' || col.key === 'last_seen';
if (numeric) {
const x = a[col.key] == null ? -Infinity : Number(a[col.key]);
const y = b[col.key] == null ? -Infinity : Number(b[col.key]);
const value = (row) => col.key === 'clients'
? (row.client_count == null ? (row.clients || []).length : row.client_count)
: row[col.key];
const x = value(a) == null ? -Infinity : Number(value(a));
const y = value(b) == null ? -Infinity : Number(value(b));
return (x - y) * dir;
}
const xs = String(a[col.key] == null ? '' : a[col.key]).toLowerCase();
const ys = String(b[col.key] == null ? '' : b[col.key]).toLowerCase();
const xs = String(col.search ? col.search(a) : a[col.key] == null ? '' : a[col.key]).toLowerCase();
const ys = String(col.search ? col.search(b) : b[col.key] == null ? '' : b[col.key]).toLowerCase();
return xs.localeCompare(ys) * dir;
}
@@ -2060,6 +2098,25 @@ views.recon = (root) => {
h('span', { class: 'recon-focus-detail-label', text: k }),
h('span', { text: v })));
});
const confirmed = h('div', { class: 'recon-focus-body recon-confirmed-clients' });
focusSidebar.appendChild(confirmed);
confirmed.appendChild(h('div', { class: 'recon-focus-body-title', text: 'Confirmed Clients' }));
const clients = (ap.clients || []).filter((client) => client && client.mac);
if (!clients.length) {
confirmed.appendChild(h('div', { class: 'empty', text: 'No confirmed clients' }));
} else {
clients.forEach((client) => {
const sources = (client.associations || []).reduce((all, association) => {
(association.sources || []).forEach((source) => { if (all.indexOf(source) === -1) all.push(source); });
return all;
}, []);
confirmed.appendChild(h('div', { class: 'recon-focus-client' },
h('span', { class: 'recon-focus-client-mac', text: client.mac }),
h('span', { text: displayIdentity(client.vendor) }),
h('span', { class: 'recon-focus-client-sources', text: sources.join(', ') || '--' })));
});
}
}
function toggleFocus(ap) {