diff --git a/payload/user/remote_access/pager-webui/www/css/app.css b/payload/user/remote_access/pager-webui/www/css/app.css index 49f7363..e072ea5 100644 --- a/payload/user/remote_access/pager-webui/www/css/app.css +++ b/payload/user/remote_access/pager-webui/www/css/app.css @@ -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; } diff --git a/payload/user/remote_access/pager-webui/www/js/views.js b/payload/user/remote_access/pager-webui/www/js/views.js index 983c5c0..adff3fb 100644 --- a/payload/user/remote_access/pager-webui/www/js/views.js +++ b/payload/user/remote_access/pager-webui/www/js/views.js @@ -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) {