feat: show recon client identities and associations
This commit is contained in:
@@ -409,6 +409,9 @@ html.dark .recon-row-compare td { background: rgba(25, 118, 210, .18); }
|
|||||||
.recon-focus-twin:hover { background: #689f38; }
|
.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 { 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-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; }
|
.recon-sort-arrow { color: var(--muted); font-size: 11px; }
|
||||||
th.recon-sorted { color: var(--primary); }
|
th.recon-sorted { color: var(--primary); }
|
||||||
.recon-per { width: auto; }
|
.recon-per { width: auto; }
|
||||||
|
|||||||
@@ -959,7 +959,8 @@ const RECON_AP_COLS = [
|
|||||||
{ key: 'band', label: 'Band', render: (a) => a.band || '--' },
|
{ key: 'band', label: 'Band', render: (a) => a.band || '--' },
|
||||||
{ key: 'channel', label: 'Channel', render: (a) => a.channel == null ? '--' : a.channel },
|
{ key: 'channel', label: 'Channel', render: (a) => a.channel == null ? '--' : a.channel },
|
||||||
{ key: 'signal', label: 'Signal', render: (a) => a.signal == null ? '--' : dbmCell(a.signal) },
|
{ 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: 'encryption', label: 'Encryption', render: (a) => a.encryption || '--' },
|
||||||
{ key: 'first_seen', label: 'First Seen', render: (a) => fmtShortTime(a.first_seen) },
|
{ key: 'first_seen', label: 'First Seen', render: (a) => fmtShortTime(a.first_seen) },
|
||||||
{ key: 'last_seen', label: 'Last Seen', render: (a) => fmtShortTime(a.last_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: 'mac', label: 'Client MAC', render: (c) => c.mac },
|
||||||
{ key: 'signal', label: 'Signal', render: (c) => c.signal == null ? '--' : c.signal + ' dBm' },
|
{ key: 'signal', label: 'Signal', render: (c) => c.signal == null ? '--' : c.signal + ' dBm' },
|
||||||
{ key: 'freq', label: 'Frequency', render: (c) => c.freq || '--' },
|
{ 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) {
|
function reconBandOf(freq) {
|
||||||
if (freq == null) return null;
|
if (freq == null) return null;
|
||||||
if (freq >= 2400 && freq < 2500) return '2.4';
|
if (freq >= 2400 && freq < 2500) return '2.4';
|
||||||
@@ -1003,8 +1032,8 @@ function reconBandLabel(band) {
|
|||||||
|
|
||||||
function reconDefaultCols() {
|
function reconDefaultCols() {
|
||||||
return {
|
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 },
|
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 }
|
client: { mac: true, signal: true, freq: true, packets: true, vendor: true, associated_ssid: true }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1012,6 +1041,9 @@ function reconLoadCols() {
|
|||||||
try {
|
try {
|
||||||
const v = JSON.parse(localStorage.getItem('pw_recon_cols'));
|
const v = JSON.parse(localStorage.getItem('pw_recon_cols'));
|
||||||
if (v && v.ap && v.client) {
|
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;
|
if (typeof v.ap.compare !== 'boolean') v.ap.compare = true;
|
||||||
return v;
|
return v;
|
||||||
}
|
}
|
||||||
@@ -1022,7 +1054,10 @@ function reconLoadCols() {
|
|||||||
function reconFiltered(rows, q, colsArr) {
|
function reconFiltered(rows, q, colsArr) {
|
||||||
const ql = (q || '').toLowerCase();
|
const ql = (q || '').toLowerCase();
|
||||||
if (!ql) return rows;
|
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) {
|
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';
|
col.key === 'first_seen' || col.key === 'last_seen';
|
||||||
if (numeric) {
|
if (numeric) {
|
||||||
const x = a[col.key] == null ? -Infinity : Number(a[col.key]);
|
const value = (row) => col.key === 'clients'
|
||||||
const y = b[col.key] == null ? -Infinity : Number(b[col.key]);
|
? (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;
|
return (x - y) * dir;
|
||||||
}
|
}
|
||||||
const xs = String(a[col.key] == null ? '' : a[col.key]).toLowerCase();
|
const xs = String(col.search ? col.search(a) : a[col.key] == null ? '' : a[col.key]).toLowerCase();
|
||||||
const ys = String(b[col.key] == null ? '' : b[col.key]).toLowerCase();
|
const ys = String(col.search ? col.search(b) : b[col.key] == null ? '' : b[col.key]).toLowerCase();
|
||||||
return xs.localeCompare(ys) * dir;
|
return xs.localeCompare(ys) * dir;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2060,6 +2098,25 @@ views.recon = (root) => {
|
|||||||
h('span', { class: 'recon-focus-detail-label', text: k }),
|
h('span', { class: 'recon-focus-detail-label', text: k }),
|
||||||
h('span', { text: v })));
|
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) {
|
function toggleFocus(ap) {
|
||||||
|
|||||||
Reference in New Issue
Block a user