@@ -28,7 +28,14 @@ const table = (columns, rows, rowAttrs) => {
const tb = h ( 'tbody' ) ;
( rows || [ ] ) . forEach ( ( r ) => {
const trr = h ( 'tr' , rowAttrs ? rowAttrs ( r ) : { } ) ;
columns . forEach ( ( c ) => trr . appendChild ( h ( 'td' , { text : c . render ? c . render ( r ) : r [ c . key ] } ) ) ) ;
columns . forEach ( ( c ) => {
const v = c . render ? c . render ( r ) : r [ c . key ] ;
const td = h ( 'td' ) ;
if ( v == null ) td . textContent = '' ;
else if ( typeof v === 'string' || typeof v === 'number' ) td . textContent = String ( v ) ;
else td . appendChild ( v ) ;
trr . appendChild ( td ) ;
} ) ;
tb . appendChild ( trr ) ;
} ) ;
t . appendChild ( tb ) ;
@@ -41,6 +48,16 @@ const fmtTime = (ts) => {
return d . toLocaleString ( ) ;
} ;
const fmtShortTime = ( ts ) => {
if ( ! ts ) return '--' ;
const d = new Date ( ts * 1000 ) ;
const now = new Date ( ) ;
const sameDay = d . getFullYear ( ) === now . getFullYear ( ) && d . getMonth ( ) === now . getMonth ( ) && d . getDate ( ) === now . getDate ( ) ;
const hm = d . toLocaleTimeString ( [ ] , { hour : '2-digit' , minute : '2-digit' , second : '2-digit' } ) ;
if ( sameDay ) return hm ;
return ( d . getMonth ( ) + 1 ) + '/' + d . getDate ( ) + ' ' + hm ;
} ;
const fmtDur = ( secs ) => {
if ( secs == null ) return '--' ;
const d = Math . floor ( secs / 86400 ) , hh = Math . floor ( ( secs % 86400 ) / 3600 ) ,
@@ -1044,6 +1061,8 @@ views.pineap_filtering = (root) => {
const RECON _TABS = [
{ label : 'Scanning' , hash : '#/recon' } ,
{ label : 'Survey' , hash : '#/recon/survey' } ,
{ label : 'Reports' , hash : '#/recon/reports' } ,
{ label : 'Handshakes' , hash : '#/recon/handshakes' }
] ;
@@ -1054,9 +1073,13 @@ const RECON_CHANNEL_COLORS = ['#FC68AC','#4545FF','#19DE8F','#FF294A','#23E8DB',
const RECON _AP _COLS = [
{ key : 'ssid' , label : 'SSID' , render : ( a ) => a . ssid || '(hidden)' } ,
{ key : 'bssid' , label : 'MAC' , render : ( a ) => a . bssid || '--' } ,
{ 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 ? '--' : a . signal + ' dBm' } ,
{ key : 'signal' , label : 'Signal' , render : ( a ) => a . signal == null ? '--' : dbmCell ( a . signal ) } ,
{ key : 'vendor' , label : 'Vendor' , render : ( a ) => a . vendor || '--' } ,
{ 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 ) } ,
{ key : 'hidden' , label : 'Hidden' , render : ( a ) => a . hidden ? 'Yes' : 'No' }
] ;
const RECON _CLIENT _COLS = [
@@ -1066,9 +1089,38 @@ const RECON_CLIENT_COLS = [
{ key : 'packets' , label : 'Packets' , render : ( c ) => c . packets || 0 }
] ;
function reconBandOf ( freq ) {
if ( freq == null ) return null ;
if ( freq >= 2400 && freq < 2500 ) return '2.4' ;
if ( freq >= 4900 && freq < 5900 ) return '5' ;
if ( freq >= 5900 && freq < 7125 ) return '6' ;
return null ;
}
function reconSigColor ( dbm ) {
if ( dbm == null ) return '#9e9e9e' ;
if ( dbm >= - 50 ) return '#2e7d32' ;
if ( dbm >= - 67 ) return '#f9a825' ;
if ( dbm >= - 80 ) return '#ef6c00' ;
return '#c62828' ;
}
function dbmCell ( dbm ) {
const pct = dbm == null ? 0 : Math . max ( 0 , Math . min ( 100 , ( ( dbm + 100 ) / 60 ) * 100 ) ) ;
const color = reconSigColor ( dbm ) ;
const bar = h ( 'span' , { class : 'recon-dbm-bar' } ,
h ( 'span' , { class : 'recon-dbm-fill' , style : 'width:' + pct . toFixed ( 0 ) + '%;background:' + color } ) ) ;
return h ( 'span' , { class : 'recon-dbm-cell' } ,
bar , h ( 'span' , { class : 'recon-dbm-val' , style : 'color:' + color , text : ( dbm == null ? '--' : dbm + ' dBm' ) } ) ) ;
}
function reconBandLabel ( band ) {
return band == null ? '--' : band + ' GHz' ;
}
function reconDefaultCols ( ) {
return {
ap : { ssid : true , bssid : true , channel : true , signal : true , encryption : true , hidden : true } ,
ap : { 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 }
} ;
}
@@ -1104,7 +1156,8 @@ 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 === '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 ] ) ;
@@ -1124,7 +1177,8 @@ views.recon = (root) => {
apPer : reconPer ( 'ap' , 10 ) , clientPer : reconPer ( 'client' , 10 ) ,
apSort : null , clientSort : null , focusAp : null , autoFollow : false ,
scanActive : false , detailLoading : false , detailLoadingId : null ,
detailQueued : false , detailId : null } ;
detailQueued : false , detailId : null ,
apBand : 'all' , apEnc : 'all' , gps : null , wigle : null } ;
const cols = reconLoadCols ( ) ;
// ---- title cards ----
@@ -1192,9 +1246,18 @@ views.recon = (root) => {
loadDetail ( ) ;
} ) ;
psRow . appendChild ( sel ) ;
psRow . appendChild ( iconBtn ( 'file_download' , 'Download scan JSON' , ( ) => {
const dlJson = iconBtn ( 'file_download' , 'Download scan JSON' , ( ) => {
if ( state . selected != null ) window . location = App . apiBase + '/api/recon/scans/' + state . selected + '/download/json' ;
} ) ) ;
} ) ;
const dlCsv = iconBtn ( 'table_chart' , 'Download scan CSV' , ( ) => {
if ( state . selected != null ) window . location = App . apiBase + '/api/recon/scans/' + state . selected + '/download/csv' ;
} ) ;
const dlHtml = iconBtn ( 'description' , 'Download scan HTML report' , ( ) => {
if ( state . selected != null ) window . location = App . apiBase + '/api/recon/scans/' + state . selected + '/download/html' ;
} ) ;
psRow . appendChild ( dlJson ) ;
psRow . appendChild ( dlCsv ) ;
psRow . appendChild ( dlHtml ) ;
psRow . appendChild ( iconBtn ( 'delete' , 'Delete scan' , ( ) => {
if ( state . selected == null ) return ;
if ( ! confirm ( 'Delete scan #' + state . selected + '? This cannot be undone.' ) ) return ;
@@ -1216,7 +1279,47 @@ views.recon = (root) => {
durSel . addEventListener ( 'change' , ( ) => localStorage . setItem ( 'pw_scan_duration' , durSel . value ) ) ;
scanBar . appendChild ( durSel ) ;
scanBar . appendChild ( h ( 'span' , { class : 'toolbar-spacer' } ) ) ;
const gpsPill = h ( 'button' , { class : 'recon-pill recon-pill-gps' , title : 'GPS status (click to auto-bind the Glytch GPS module)' } ) ;
const wiglePill = h ( 'button' , { class : 'recon-pill recon-pill-wigle' , title : 'WiGLE logging (click to toggle)' } ) ;
scanBar . appendChild ( gpsPill ) ;
scanBar . appendChild ( wiglePill ) ;
scanBar . appendChild ( iconBtn ( 'settings' , 'Recon settings' , ( ) => sidebar . classList . toggle ( 'hidden' ) ) ) ;
function renderPills ( ) {
const g = state . gps || { } ;
if ( g . lock ) {
gpsPill . className = 'recon-pill recon-pill-gps on' ;
gpsPill . textContent = 'GPS ' + ( g . lat != null ? Number ( g . lat ) . toFixed ( 5 ) : '--' ) + ', ' + ( g . lon != null ? Number ( g . lon ) . toFixed ( 5 ) : '--' ) + ( g . satellites ? ' · ' + g . satellites + ' sats' : '' ) ;
} else if ( g . present ) {
gpsPill . className = 'recon-pill recon-pill-gps' ;
gpsPill . textContent = 'GPS no fix' ;
} else if ( g . gpsd _running ) {
gpsPill . className = 'recon-pill recon-pill-gps' ;
gpsPill . textContent = 'GPS no device' ;
} else {
gpsPill . className = 'recon-pill recon-pill-gps' ;
gpsPill . textContent = 'GPS off' ;
}
wiglePill . className = 'recon-pill recon-pill-wigle ' + ( state . wigle ? 'on' : '' ) ;
wiglePill . textContent = state . wigle ? 'WiGLE on' : 'WiGLE off' ;
}
gpsPill . addEventListener ( 'click' , ( ) => {
PagerAPI . post ( '/api/recon/gps/configure' , { } ) . then ( ( r ) => {
state . gps = r . data ;
renderPills ( ) ;
if ( r . data . error ) App . toast ( r . data . error , 'error' ) ;
else if ( r . data . lock ) App . toast ( 'GPS locked: ' + Number ( r . data . lat ) . toFixed ( 5 ) + ', ' + Number ( r . data . lon ) . toFixed ( 5 ) ) ;
else App . toast ( ( r . data . note || 'GPS bound, waiting for a fix' ) ) ;
} ) . catch ( ( err ) => App . toast ( ( err && err . message ) || 'GPS configure failed' , 'error' ) ) ;
} ) ;
wiglePill . addEventListener ( 'click' , ( ) => {
const next = ! state . wigle ;
wiglePill . disabled = true ;
PagerAPI . post ( '/api/recon/wigle' , { enable : next } ) . then ( ( r ) => {
state . wigle = next ;
App . toast ( next ? ( 'WiGLE logging started' + ( r . data && r . data . filename ? ' → ' + r . data . filename : '' ) ) : 'WiGLE logging stopped' ) ;
} ) . catch ( ( err ) => App . toast ( ( err && err . message ) || 'WiGLE toggle failed' , 'error' ) )
. finally ( ( ) => { wiglePill . disabled = false ; renderPills ( ) ; } ) ;
} ) ;
let pendingScan = false ;
scanToggle . addEventListener ( 'change' , ( ) => {
if ( pendingScan ) { scanToggle . checked = ! scanToggle . checked ; return ; }
@@ -1252,8 +1355,10 @@ views.recon = (root) => {
h ( 'span' , { class : 'recon-settings-title' , text : 'Recon Settings' } ) ,
btn ( '× ' , ( ) => sidebar . classList . add ( 'hidden' ) , 'ghost' ) ) ) ;
const colDefs = {
ap : [ [ 'ssid' , 'Show SSID' ] , [ 'bssid' , 'Show MAC' ] , [ 'channel ' , 'Show Channel ' ] ,
[ 'signa l' , 'Show Signa l' ] , [ 'encryption ' , 'Show Encryption ' ] , [ 'hidden ' , 'Show Hidden' ] ] ,
ap : [ [ 'ssid' , 'Show SSID' ] , [ 'bssid' , 'Show MAC' ] , [ 'band ' , 'Show Band ' ] ,
[ 'channe l' , 'Show Channe l' ] , [ 'signal ' , 'Show Signal ' ] , [ 'vendor ' , 'Show Vendor' ] ,
[ 'encryption' , 'Show Encryption' ] , [ 'first_seen' , 'Show First Seen' ] ,
[ 'last_seen' , 'Show Last Seen' ] , [ 'hidden' , 'Show Hidden' ] ] ,
client : [ [ 'mac' , 'Show MAC' ] , [ 'signal' , 'Show Signal' ] , [ 'freq' , 'Show Frequency' ] , [ 'packets' , 'Show Packets' ] ]
} ;
Object . keys ( colDefs ) . forEach ( ( grp ) => {
@@ -1348,6 +1453,32 @@ views.recon = (root) => {
const cliCard = h ( 'div' , { class : 'section recon-scan-results-card' } ) ;
root . appendChild ( cliCard ) ;
// ---- band / encryption filter chips ----
const chipRow = h ( 'div' , { class : 'recon-chips-row' } ) ;
apCard . appendChild ( chipRow ) ;
function renderChips ( ) {
chipRow . innerHTML = '' ;
const groups = [
[ 'Band' , 'apBand' , [ [ 'all' , 'All' ] , [ '2.4' , '2.4 GHz' ] , [ '5' , '5 GHz' ] , [ '6' , '6 GHz' ] ] ] ,
[ 'Encryption' , 'apEnc' , [ [ 'all' , 'All' ] , [ 'Open' , 'Open' ] , [ 'WEP' , 'WEP' ] , [ 'WPA' , 'WPA' ] ,
[ 'WPA2' , 'WPA2' ] , [ 'WPA3' , 'WPA3' ] , [ 'Enterprise' , 'Enterprise' ] ] ]
] ;
groups . forEach ( ( [ label , key , opts ] ) => {
chipRow . appendChild ( h ( 'span' , { class : 'recon-chips-label' , text : label } ) ) ;
opts . forEach ( ( [ v , t ] ) => {
const c = h ( 'button' , { class : 'recon-chip' + ( state [ key ] === v ? ' active' : '' ) , text : t } ) ;
c . addEventListener ( 'click' , ( ) => {
state [ key ] = v ;
state . apPage = 0 ;
renderChips ( ) ;
renderTables ( ) ;
} ) ;
chipRow . appendChild ( c ) ;
} ) ;
} ) ;
}
renderChips ( ) ;
function buildPaginator ( key ) {
const mk = ( id , icon , title , fn ) => {
const b = h ( 'button' , { class : 'icon-btn' , id : key + '-' + id , title : title } ) ;
@@ -1363,12 +1494,20 @@ views.recon = (root) => {
mk ( 'last' , 'last_page' , 'Last page' , ( ) => { state [ key + 'Page' ] = Math . max ( 0 , reconPageCount ( key ) - 1 ) ; renderTables ( ) ; } ) ) ;
}
function reconPageCount ( key ) {
function filteredRows ( key ) {
const d = state . detail || { } ;
const rows = key === 'ap' ? ( d . aps || [ ] ) : ( d . clients || [ ] ) ;
const colsArr = key === 'ap' ? RECON _AP _COLS : RECON _CLIENT _COLS ;
const q = key === 'ap' ? state . apSearch : state . clientSearch ;
return Math . max ( 1 , Math . ceil ( reconFiltered ( rows , q , colsArr ) . length / state [ key + 'Per' ] ) ) ;
let out = reconFiltered ( rows , key === 'ap' ? state . apSearch : state . clientSearch ,
key === 'ap' ? RECON _AP _COLS : RECON _CLIENT _COLS ) ;
if ( key === 'ap' ) {
if ( state . apBand !== 'all' ) out = out . filter ( ( a ) => ( a . band || '' ) === state . apBand ) ;
if ( state . apEnc !== 'all' ) out = out . filter ( ( a ) => reconEncBucket ( a . encryption ) === state . apEnc ) ;
}
return out ;
}
function reconPageCount ( key ) {
return Math . max ( 1 , Math . ceil ( filteredRows ( key ) . length / state [ key + 'Per' ] ) ) ;
}
function perSelect ( key ) {
@@ -1464,8 +1603,8 @@ views.recon = (root) => {
function renderTables ( ) {
const d = state . detail || { aps : [ ] , clients : [ ] , handshakes : [ ] } ;
const apF = reconFiltered ( d . aps || [ ] , state . apSearch , RECON _AP _COLS ) ;
const cliF = reconFiltered ( d . clients || [ ] , state . clientSearch , RECON _CLIENT _COLS ) ;
const apF = filteredRows ( 'ap' ) ;
const cliF = filteredRows ( 'client' ) ;
renderTable ( apBody , 'ap' , sortRows ( apF , 'ap' , RECON _AP _COLS ) , RECON _AP _COLS , 'No access points in this scan.' ) ;
renderTable ( cliBody , 'client' , sortRows ( cliF , 'client' , RECON _CLIENT _COLS ) , RECON _CLIENT _COLS , 'No clients in this scan.' ) ;
}
@@ -1479,7 +1618,7 @@ views.recon = (root) => {
MiniChart . doughnut ( land , [
{ label : 'Access Points' , value : n , color : RECON _LANDSCAPE _COLORS [ 0 ] } ,
{ label : 'Clients' , value : c , color : RECON _LANDSCAPE _COLORS [ 1 ] } ,
{ label : 'Unassociated' , value : 0 , color : RECON _LANDSCAPE _COLORS [ 2 ] }
{ label : 'Unassociated' , value : d . unassociated || 0 , color : RECON _LANDSCAPE _COLORS [ 2 ] }
] , { legend : true , height : 130 } ) ;
land . classList . remove ( 'hidden' ) ;
landEmpty . classList . add ( 'hidden' ) ;
@@ -1595,6 +1734,11 @@ views.recon = (root) => {
App . toast ( 'Scan complete' ) ;
}
} ) . catch ( ( ) => { } ) ;
PagerAPI . get ( '/api/recon/gps' ) . then ( ( r ) => {
state . gps = r . data ;
state . wigle = ! ! ( r . data || { } ) . wigle ;
renderPills ( ) ;
} ) . catch ( ( ) => { } ) ;
}
PagerAPI . get ( '/api/pineap/get_config' ) . then ( ( r ) => {
@@ -1610,6 +1754,506 @@ views.recon = (root) => {
return { destroy : ( ) => clearInterval ( pollIv ) } ;
} ;
const SURVEY _COMPARE _COLORS = [ '#2ecc71' , '#2980b9' , '#8e44ad' , '#e67e22' , '#c0392b' , '#16a085' ] ;
const SURVEY _MAX _HISTORY = 90 ;
views . recon _survey = ( root ) => {
root . appendChild ( h ( 'h1' , { class : 'page-title' , text : 'Recon' } ) ) ;
tabBar ( root , RECON _TABS , '#/recon/survey' ) ;
const state = {
live : { scan : null , aps : [ ] , clients : [ ] , handshakes : [ ] , unassociated : 0 , gps : { } , recording : null } ,
band : 'all' , search : '' ,
history : { } , compare : [ ] , discover : null ,
gps : { } , wigle : false ,
recording : null , recBusy : false , scanBusy : false , updated : 0
} ;
// ---- live header bar ----
const liveBar = h ( 'div' , { class : 'section survey-live-bar' } ) ;
root . appendChild ( liveBar ) ;
const liveStatus = h ( 'span' , { class : 'survey-live-status' , text : 'Waiting for recon data…' } ) ;
liveBar . appendChild ( liveStatus ) ;
liveBar . appendChild ( h ( 'span' , { class : 'toolbar-spacer' } ) ) ;
const durSel = h ( 'select' , { class : 'sel survey-dur' } ) ;
[ [ 30 , '30s' ] , [ 60 , '1m' ] , [ 120 , '2m' ] , [ 300 , '5m' ] , [ 600 , '10m' ] ]
. forEach ( ( [ v , t ] ) => durSel . appendChild ( h ( 'option' , { value : String ( v ) , text : t } ) ) ) ;
durSel . value = localStorage . getItem ( 'pw_scan_duration' ) || '30' ;
durSel . addEventListener ( 'change' , ( ) => localStorage . setItem ( 'pw_scan_duration' , durSel . value ) ) ;
const scanBtn = btn ( 'Scan now' , ( ) => {
if ( state . scanBusy ) return ;
state . scanBusy = true ;
scanBtn . disabled = true ;
PagerAPI . post ( '/api/recon/start' , { scan _time : parseInt ( durSel . value , 10 ) } )
. then ( ( ) => App . toast ( 'Timed scan started — live view will refresh' ) )
. catch ( ( err ) => App . toast ( ( err && err . message ) || 'Scan start failed' , 'error' ) )
. finally ( ( ) => { state . scanBusy = false ; scanBtn . disabled = false ; } ) ;
} ) ;
liveBar . appendChild ( durSel ) ;
liveBar . appendChild ( scanBtn ) ;
const gpsPill = h ( 'button' , { class : 'survey-pill' , title : 'GPS status (click to auto-bind the Glytch GPS module)' } ) ;
gpsPill . addEventListener ( 'click' , ( ) => {
PagerAPI . post ( '/api/recon/gps/configure' , { } ) . then ( ( r ) => {
state . gps = r . data || { } ;
if ( r . data && r . data . error ) App . toast ( r . data . error , 'error' ) ;
else if ( state . gps . lock ) App . toast ( 'GPS locked' ) ;
else App . toast ( ( r . data && r . data . note ) || 'GPS bound, waiting for a fix' ) ;
renderPills ( ) ;
} ) . catch ( ( err ) => App . toast ( ( err && err . message ) || 'GPS configure failed' , 'error' ) ) ;
} ) ;
liveBar . appendChild ( gpsPill ) ;
const wigleCb = h ( 'input' , { type : 'checkbox' } ) ;
const wigleSwitch = h ( 'label' , { class : 'switch survey-wigle' } , wigleCb , h ( 'span' , { class : 'track' } ) , ' WiGLE' ) ;
wigleCb . addEventListener ( 'change' , ( ) => {
wigleCb . disabled = true ;
PagerAPI . post ( '/api/recon/wigle' , { enable : wigleCb . checked } )
. then ( ( r ) => {
state . wigle = wigleCb . checked ;
App . toast ( state . wigle ? ( 'WiGLE logging started' + ( r . data && r . data . filename ? ' → ' + r . data . filename : '' ) ) : 'WiGLE logging stopped' ) ;
} )
. catch ( ( err ) => { wigleCb . checked = ! wigleCb . checked ; App . toast ( ( err && err . message ) || 'WiGLE toggle failed' , 'error' ) ; } )
. finally ( ( ) => { wigleCb . disabled = false ; } ) ;
} ) ;
liveBar . appendChild ( wigleSwitch ) ;
// ---- recording controls ----
const recCard = h ( 'div' , { class : 'section survey-rec-card' } ) ;
root . appendChild ( recCard ) ;
const recName = h ( 'input' , { class : 'survey-rec-name' , placeholder : 'Survey name (optional)' } ) ;
const recStatus = h ( 'span' , { class : 'survey-rec-status' , text : 'Not recording' } ) ;
const recBtn = btn ( 'Record' , ( ) => toggleRecording ( ) , '' ) ;
recCard . appendChild ( recName ) ;
recCard . appendChild ( recBtn ) ;
recCard . appendChild ( recStatus ) ;
function toggleRecording ( ) {
if ( state . recBusy ) return ;
state . recBusy = true ;
recBtn . disabled = true ;
const active = ! ! ( state . recording && state . recording . active ) ;
const req = active
? PagerAPI . post ( '/api/recon/survey/stop' , { } )
: PagerAPI . post ( '/api/recon/survey/start' , { name : recName . value } ) ;
req . then ( ( r ) => {
if ( active ) {
App . toast ( 'Survey stopped — ' + ( r . data . samples || 0 ) + ' samples saved. See Reports.' ) ;
recName . value = '' ;
} else {
App . toast ( 'Survey recording started' ) ;
}
return PagerAPI . get ( '/api/recon/survey/live' ) ;
} ) . then ( ( r ) => {
state . recording = ( r . data || { } ) . recording || null ;
renderRecording ( ) ;
} ) . catch ( ( err ) => App . toast ( ( err && err . message ) || 'Survey control failed' , 'error' ) )
. finally ( ( ) => { state . recBusy = false ; recBtn . disabled = false ; } ) ;
}
function renderRecording ( ) {
const r = state . recording ;
if ( r && r . active ) {
recBtn . textContent = 'Stop' ;
recBtn . classList . add ( 'danger' ) ;
const mins = Math . max ( 1 , Math . round ( ( Date . now ( ) / 1000 - r . started ) / 60 ) ) ;
recStatus . textContent = '● Recording "' + r . name + '" — ' + r . samples + ' samples, ~' + mins + ' min' ;
recStatus . classList . add ( 'on' ) ;
} else {
recBtn . textContent = 'Record' ;
recBtn . classList . remove ( 'danger' ) ;
recStatus . textContent = 'Not recording' ;
recStatus . classList . remove ( 'on' ) ;
}
}
// ---- band filter chips + network search ----
const filterRow = h ( 'div' , { class : 'recon-chips-row survey-filter-row' } ) ;
root . appendChild ( filterRow ) ;
filterRow . appendChild ( h ( 'span' , { class : 'recon-chips-label' , text : 'Band' } ) ) ;
[ [ 'all' , 'All' ] , [ '2.4' , '2.4 GHz' ] , [ '5' , '5 GHz' ] , [ '6' , '6 GHz' ] ] . forEach ( ( [ v , t ] ) => {
const c = h ( 'button' , { class : 'recon-chip' + ( state . band === v ? ' active' : '' ) , text : t } ) ;
c . addEventListener ( 'click' , ( ) => { state . band = v ; renderChips ( ) ; renderTable ( ) ; } ) ;
filterRow . appendChild ( c ) ;
} ) ;
filterRow . appendChild ( h ( 'span' , { class : 'recon-chips-label' , text : 'Search' } ) ) ;
const searchIn = h ( 'input' , { class : 'recon-search survey-search' , placeholder : 'SSID or MAC' } ) ;
searchIn . addEventListener ( 'input' , ( ) => { state . search = searchIn . value ; renderTable ( ) ; } ) ;
filterRow . appendChild ( searchIn ) ;
function renderChips ( ) {
filterRow . querySelectorAll ( '.recon-chip' ) . forEach ( ( c , i ) => {
const vals = [ 'all' , '2.4' , '5' , '6' ] ;
if ( i < vals . length ) c . classList . toggle ( 'active' , state . band === vals [ i ] ) ;
} ) ;
}
// ---- channel occupancy ----
const chanCard = h ( 'div' , { class : 'section' } ) ;
chanCard . appendChild ( h ( 'h2' , { text : 'Channel Occupancy' } ) ) ;
const chanBox = h ( 'div' , { class : 'survey-chan-box' } ) ;
chanCard . appendChild ( chanBox ) ;
root . appendChild ( chanCard ) ;
function renderChannels ( ) {
chanBox . innerHTML = '' ;
const counts = { } ;
( state . live . aps || [ ] ) . forEach ( ( a ) => {
const band = a . band || '?' ;
const ch = a . channel == null ? '?' : a . channel ;
const key = band + '|' + ch ;
counts [ key ] = ( counts [ key ] || 0 ) + 1 ;
} ) ;
const bands = { } ;
Object . keys ( counts ) . forEach ( ( k ) => {
const [ band , ch ] = k . split ( '|' ) ;
( bands [ band ] = bands [ band ] || [ ] ) . push ( { ch , n : counts [ k ] } ) ;
} ) ;
const bandOrder = [ '2.4' , '5' , '6' , '?' ] ;
const maxN = Math . max ( 1 , ... Object . keys ( counts ) . map ( ( k ) => counts [ k ] ) ) ;
Object . keys ( bands ) . sort ( ( a , b ) => {
const ia = bandOrder . indexOf ( a ) , ib = bandOrder . indexOf ( b ) ;
return ( ia === - 1 ? 9 : ia ) - ( ib === - 1 ? 9 : ib ) ;
} ) . forEach ( ( band ) => {
chanBox . appendChild ( h ( 'div' , { class : 'survey-chan-band' , text : band === '?' ? 'Unknown' : band + ' GHz' } ) ) ;
bands [ band ] . sort ( ( x , y ) => {
if ( x . ch === '?' ) return 1 ;
if ( y . ch === '?' ) return - 1 ;
return Number ( x . ch ) - Number ( y . ch ) ;
} ) . forEach ( ( { ch , n } ) => {
const row = h ( 'div' , { class : 'survey-chan-row' } ,
h ( 'span' , { class : 'survey-chan-label' , text : 'CH ' + ch } ) ,
h ( 'span' , { class : 'survey-chan-track' } ,
h ( 'span' , { class : 'survey-chan-fill' , style : 'width:' + Math . round ( n / maxN * 100 ) + '%' } ) ) ,
h ( 'span' , { class : 'survey-chan-count' , text : String ( n ) } ) ) ;
chanBox . appendChild ( row ) ;
} ) ;
} ) ;
if ( ! Object . keys ( counts ) . length ) chanBox . appendChild ( h ( 'div' , { class : 'empty' , text : 'No access points seen yet.' } ) ) ;
}
// ---- compare ----
const cmpCard = h ( 'div' , { class : 'section' } ) ;
cmpCard . appendChild ( h ( 'h2' , { text : 'Compare APs' } ) ) ;
const cmpHint = h ( 'div' , { class : 'survey-cmp-hint' , text : 'Select up to 6 APs in the table below to compare live signal strength.' } ) ;
cmpCard . appendChild ( cmpHint ) ;
const cmpCanvas = h ( 'canvas' , { id : 'survey-compare' , style : 'width:100%;height:150px' } ) ;
cmpCard . appendChild ( cmpCanvas ) ;
const cmpLegend = h ( 'div' , { class : 'survey-cmp-legend' } ) ;
cmpCard . appendChild ( cmpLegend ) ;
root . appendChild ( cmpCard ) ;
function renderCompare ( ) {
const series = [ ] ;
const legends = [ ] ;
state . compare . forEach ( ( bssid , i ) => {
const hist = state . history [ bssid ] || [ ] ;
const pts = hist . map ( ( p ) => p . sig ) . filter ( ( v ) => v != null ) ;
const color = SURVEY _COMPARE _COLORS [ i % SURVEY _COMPARE _COLORS . length ] ;
if ( pts . length >= 2 ) series . push ( { points : pts , color : color } ) ;
const ap = state . live . aps . find ( ( a ) => a . bssid === bssid ) ;
const last = pts . length ? pts [ pts . length - 1 ] : null ;
legends . push ( h ( 'span' , { class : 'survey-cmp-legend-item' } ,
h ( 'span' , { class : 'survey-cmp-swatch' , style : 'background:' + color } ) ,
h ( 'span' , { text : ( ap && ap . ssid ) || '(hidden) ' + ( bssid || '' ) . slice ( 0 , 8 ) + '…' } ) ,
h ( 'span' , { class : 'survey-cmp-sig' , text : last == null ? '--' : last + ' dBm' } ) ) ) ;
} ) ;
cmpLegend . innerHTML = '' ;
legends . forEach ( ( l ) => cmpLegend . appendChild ( l ) ) ;
if ( typeof MiniChart !== 'undefined' && MiniChart . draw ) {
MiniChart . draw ( cmpCanvas , series , { min : - 100 , max : - 20 , grid : '#e0e0e0' } ) ;
}
}
// ---- discover ----
const discCard = h ( 'div' , { class : 'section survey-discover hidden' } ) ;
root . appendChild ( discCard ) ;
function renderDiscover ( ) {
const d = state . discover ;
if ( ! d ) { discCard . classList . add ( 'hidden' ) ; return ; }
discCard . classList . remove ( 'hidden' ) ;
discCard . innerHTML = '' ;
const head = h ( 'div' , { class : 'survey-discover-head' } ,
h ( 'span' , { class : 'survey-discover-title' , text : 'Discover: ' + ( d . ssid || '(hidden SSID)' ) } ) ,
btn ( '× ' , ( ) => { state . discover = null ; renderDiscover ( ) ; renderTable ( ) ; } , 'ghost' ) ) ;
discCard . appendChild ( head ) ;
const ap = state . live . aps . find ( ( a ) => a . bssid === d . bssid ) ;
const sig = ap != null ? ap . signal : ( d . history . length ? d . history [ d . history . length - 1 ] . sig : null ) ;
const color = reconSigColor ( sig ) ;
const readout = h ( 'div' , { class : 'survey-discover-readout' , style : 'color:' + color , text : sig == null ? '-- dBm' : sig + ' dBm' } ) ;
discCard . appendChild ( readout ) ;
discCard . appendChild ( h ( 'div' , { class : 'survey-discover-sub' , text : ( ap ? ( ap . ssid || '(hidden)' ) + ' · ' : '' ) + ( d . bssid || '' ) + ( ap && ap . channel != null ? ' · CH ' + ap . channel : '' ) + ( ap && ap . band ? ' · ' + ap . band + ' GHz' : '' ) } ) ) ;
const spark = h ( 'canvas' , { style : 'width:100%;height:70px' } ) ;
discCard . appendChild ( spark ) ;
const pts = d . history . map ( ( p ) => p . sig ) . filter ( ( v ) => v != null ) ;
if ( typeof MiniChart !== 'undefined' && MiniChart . draw && pts . length >= 2 ) {
MiniChart . draw ( spark , [ { points : pts , color : color } ] , { min : - 100 , max : - 20 , grid : '#e0e0e0' } ) ;
}
}
// ---- AP table ----
const apCard = h ( 'div' , { class : 'section' } ) ;
apCard . appendChild ( h ( 'h2' , { text : 'Live Access Points' } ) ) ;
const apBody = h ( 'div' ) ;
apCard . appendChild ( apBody ) ;
root . appendChild ( apCard ) ;
const surveyCols = [
{ key : 'ssid' , label : 'SSID' , render : ( a ) => a . ssid || '(hidden)' } ,
{ key : 'bssid' , label : 'MAC' , render : ( a ) => a . bssid || '--' } ,
{ 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 : 'encryption' , label : 'Encryption' , render : ( a ) => a . encryption || '--' } ,
{ key : 'actions' , label : 'Compare' , render : ( a ) => {
const cb = h ( 'input' , { type : 'checkbox' , title : 'Compare this AP' } ) ;
cb . checked = state . compare . indexOf ( a . bssid ) !== - 1 ;
cb . addEventListener ( 'change' , ( ) => {
if ( cb . checked ) {
if ( state . compare . length >= 6 ) { cb . checked = false ; App . toast ( 'Compare up to 6 APs' , 'error' ) ; return ; }
state . compare . push ( a . bssid ) ;
App . toast ( 'Comparing ' + ( ( a . ssid || '(hidden)' ) . length > 24 ? ( a . ssid || '(hidden)' ) . slice ( 0 , 24 ) + '…' : ( a . ssid || '(hidden)' ) ) ) ;
} else {
state . compare = state . compare . filter ( ( b ) => b !== a . bssid ) ;
}
renderCompare ( ) ;
} ) ;
return h ( 'span' , { class : 'survey-cmp-check' } , cb ) ;
} } ,
{ key : 'discover' , label : 'Discover' , render : ( a ) =>
iconBtn ( 'place' , 'Pin this AP for Discover mode' , ( ) => {
state . discover = { bssid : a . bssid , ssid : a . ssid , history : ( state . history [ a . bssid ] || [ ] ) . slice ( ) } ;
renderDiscover ( ) ;
} ) }
] ;
function renderTable ( ) {
let rows = state . live . aps || [ ] ;
if ( state . band !== 'all' ) rows = rows . filter ( ( a ) => ( a . band || '' ) === state . band ) ;
const q = state . search . toLowerCase ( ) ;
if ( q ) rows = rows . filter ( ( a ) => String ( a . ssid || '' ) . toLowerCase ( ) . indexOf ( q ) !== - 1 || String ( a . bssid || '' ) . toLowerCase ( ) . indexOf ( q ) !== - 1 ) ;
rows = rows . slice ( ) . sort ( ( a , b ) => {
const sa = a . signal == null ? - Infinity : a . signal ;
const sb = b . signal == null ? - Infinity : b . signal ;
return sb - sa ;
} ) ;
apBody . innerHTML = '' ;
if ( ! rows . length ) { apBody . appendChild ( h ( 'div' , { class : 'empty' , text : 'No access points in the latest scan.' } ) ) ; return ; }
apBody . appendChild ( table ( surveyCols , rows ) ) ;
}
function renderPills ( ) {
const g = state . gps || { } ;
if ( g . lock ) {
gpsPill . className = 'survey-pill on' ;
gpsPill . textContent = 'GPS ' + ( g . lat != null ? Number ( g . lat ) . toFixed ( 5 ) : '--' ) + ', ' + ( g . lon != null ? Number ( g . lon ) . toFixed ( 5 ) : '--' ) + ( g . satellites ? ' · ' + g . satellites + ' sats' : '' ) ;
} else if ( g . present ) {
gpsPill . className = 'survey-pill' ;
gpsPill . textContent = 'GPS no fix — tap to bind' ;
} else if ( g . gpsd _running ) {
gpsPill . className = 'survey-pill' ;
gpsPill . textContent = 'GPS no device' ;
} else {
gpsPill . className = 'survey-pill' ;
gpsPill . textContent = 'GPS off' ;
}
wigleCb . checked = ! ! state . wigle ;
}
function tick ( ) {
PagerAPI . get ( '/api/recon/survey/live' ) . then ( ( r ) => {
const d = r . data || { } ;
state . live = d ;
state . updated = Date . now ( ) ;
const scan = d . scan || null ;
liveStatus . textContent = scan
? 'Live: Scan #' + scan . id + ' — ' + ( d . aps || [ ] ) . length + ' APs, ' + ( d . clients || [ ] ) . length + ' clients, ' + ( d . unassociated || 0 ) + ' unassociated · updated ' + fmtShortTime ( Date . now ( ) / 1000 )
: 'Waiting for a scan…' ;
// history
const nowT = Date . now ( ) / 1000 ;
( d . aps || [ ] ) . forEach ( ( a ) => {
if ( a . bssid == null ) return ;
const hist = state . history [ a . bssid ] || ( state . history [ a . bssid ] = [ ] ) ;
hist . push ( { t : nowT , sig : a . signal } ) ;
while ( hist . length > SURVEY _MAX _HISTORY ) hist . shift ( ) ;
} ) ;
// drop history for APs no longer present (prune after grace)
const seen = { } ;
( d . aps || [ ] ) . forEach ( ( a ) => { if ( a . bssid != null ) seen [ a . bssid ] = true ; } ) ;
Object . keys ( state . history ) . forEach ( ( b ) => {
if ( ! seen [ b ] ) {
const hist = state . history [ b ] ;
const recent = hist . filter ( ( p ) => nowT - p . t < 30 ) ;
if ( ! recent . length ) delete state . history [ b ] ;
else state . history [ b ] = recent ;
}
} ) ;
state . gps = d . gps || { } ;
state . wigle = ! ! ( d . gps || { } ) . wigle ;
state . recording = d . recording || null ;
if ( state . discover ) {
const dh = state . history [ state . discover . bssid ] || [ ] ;
state . discover . history = dh . slice ( - SURVEY _MAX _HISTORY ) ;
const ap = ( d . aps || [ ] ) . find ( ( a ) => a . bssid === state . discover . bssid ) ;
if ( ap ) { state . discover . ssid = ap . ssid ; }
}
renderPills ( ) ;
renderRecording ( ) ;
renderChannels ( ) ;
renderCompare ( ) ;
renderDiscover ( ) ;
renderTable ( ) ;
} ) . catch ( ( ) => { } ) ;
}
tick ( ) ;
const poll = setInterval ( tick , 2000 ) ;
return { destroy : ( ) => clearInterval ( poll ) } ;
} ;
views . recon _reports = ( root ) => {
root . appendChild ( h ( 'h1' , { class : 'page-title' , text : 'Recon' } ) ) ;
tabBar ( root , RECON _TABS , '#/recon/reports' ) ;
const state = { surveys : [ ] , detail : { } } ;
const reportCard = h ( 'div' , { class : 'section' } ) ;
const surveyCard = h ( 'div' , { class : 'section' } ) ;
const wigleCard = h ( 'div' , { class : 'section' } ) ;
function dl ( path ) { window . location = App . apiBase + path ; }
function renderScans ( ) {
reportCard . appendChild ( h ( 'h2' , { text : 'Scan Reports' } ) ) ;
const box = h ( 'div' ) ;
reportCard . appendChild ( box ) ;
PagerAPI . get ( '/api/recon/scans' ) . then ( ( r ) => {
const scans = ( r . data && r . data . scans ) || [ ] ;
box . innerHTML = '' ;
if ( ! scans . length ) { box . appendChild ( h ( 'div' , { class : 'empty' , text : 'No scans recorded yet.' } ) ) ; return ; }
box . appendChild ( table (
[
{ key : 'id' , label : 'Scan' , render : ( s ) => '#' + s . id } ,
{ key : 'time' , label : 'Started' , render : ( s ) => fmtTime ( s . time ) } ,
{ key : 'aps' , label : 'APs' } ,
{ key : 'devices' , label : 'Clients' } ,
{ key : 'handshakes' , label : 'Handshakes' } ,
{ key : 'actions' , label : 'Download' , render : ( s ) => h ( 'span' , { class : 'hs-actions' } ,
iconBtn ( 'file_download' , 'JSON' , ( ) => dl ( '/api/recon/scans/' + s . id + '/download/json' ) ) ,
iconBtn ( 'table_chart' , 'CSV' , ( ) => dl ( '/api/recon/scans/' + s . id + '/download/csv' ) ) ,
iconBtn ( 'description' , 'HTML report' , ( ) => dl ( '/api/recon/scans/' + s . id + '/download/html' ) ) ) }
] ,
scans ) ) ;
} ) . catch ( ( ) => box . appendChild ( h ( 'div' , { class : 'empty' , text : 'Failed to load scans.' } ) ) ) ;
}
function renderSurveys ( ) {
surveyCard . appendChild ( h ( 'h2' , { text : 'Survey Recordings' } ) ) ;
const box = h ( 'div' ) ;
surveyCard . appendChild ( box ) ;
PagerAPI . get ( '/api/recon/surveys' ) . then ( ( r ) => {
state . surveys = ( r . data && r . data . surveys ) || [ ] ;
box . innerHTML = '' ;
if ( ! state . surveys . length ) { box . appendChild ( h ( 'div' , { class : 'empty' , text : 'No surveys recorded yet. Start one on the Survey tab.' } ) ) ; return ; }
box . appendChild ( table (
[
{ key : 'name' , label : 'Name' , render : ( s ) => s . name } ,
{ key : 'started' , label : 'Started' , render : ( s ) => fmtTime ( s . started ) } ,
{ key : 'samples' , label : 'Samples' , render : ( s ) => s . samples } ,
{ key : 'size' , label : 'Size' , render : ( s ) => fmtBytes ( s . size ) } ,
{ key : 'actions' , label : 'Export' , render : ( s ) => h ( 'span' , { class : 'hs-actions' } ,
iconBtn ( 'file_download' , 'JSON' , ( ) => dl ( '/api/recon/surveys/' + s . id + '/download/json' ) ) ,
iconBtn ( 'table_chart' , 'CSV' , ( ) => dl ( '/api/recon/surveys/' + s . id + '/download/csv' ) ) ,
iconBtn ( 'description' , 'HTML report' , ( ) => dl ( '/api/recon/surveys/' + s . id + '/download/html' ) ) ,
iconBtn ( 'delete' , 'Delete survey' , ( ) => {
if ( ! confirm ( 'Delete survey "' + s . name + '"? This cannot be undone.' ) ) return ;
PagerAPI . del ( '/api/recon/surveys/' + s . id ) . then ( ( ) => { App . toast ( 'Survey deleted' ) ; renderSurveys ( ) ; } ) . catch ( ( err ) => App . toast ( ( err && err . message ) || 'Delete failed' , 'error' ) ) ;
} ) ) }
] ,
state . surveys ) ) ;
box . appendChild ( h ( 'div' , { class : 'survey-detail-hint' , text : 'Click a survey name below to expand its AP signal aggregates.' } ) ) ;
const detailBox = h ( 'div' ) ;
box . appendChild ( detailBox ) ;
state . surveys . forEach ( ( s ) => {
const row = h ( 'div' , { class : 'survey-detail-row' , text : s . name } ) ;
row . addEventListener ( 'click' , ( ) => {
if ( state . detail [ s . id ] && state . detail [ s . id ] . open ) {
state . detail [ s . id ] . open = false ;
row . classList . remove ( 'open' ) ;
const b = detailBox . querySelector ( '[data-sid="' + s . id + '"]' ) ;
if ( b ) b . remove ( ) ;
return ;
}
if ( ! state . detail [ s . id ] ) state . detail [ s . id ] = { open : false , data : null } ;
state . detail [ s . id ] . open = true ;
row . classList . add ( 'open' ) ;
const body = h ( 'div' , { class : 'survey-detail-body' , 'data-sid' : s . id } ) ;
detailBox . appendChild ( body ) ;
body . appendChild ( h ( 'div' , { class : 'empty' , text : 'Loading…' } ) ) ;
PagerAPI . get ( '/api/recon/surveys/' + s . id ) . then ( ( r2 ) => {
const d = r2 . data || { } ;
state . detail [ s . id ] . data = d ;
body . innerHTML = '' ;
const gpsTxt = d . gps _fixes
? d . gps _fixes + ' fixes · first ' + ( d . first _gps && d . first _gps . lat != null ? Number ( d . first _gps . lat ) . toFixed ( 5 ) + ', ' + Number ( d . first _gps . lon ) . toFixed ( 5 ) : '--' ) + ' · last ' + ( d . last _gps && d . last _gps . lat != null ? Number ( d . last _gps . lat ) . toFixed ( 5 ) + ', ' + Number ( d . last _gps . lon ) . toFixed ( 5 ) : '--' )
: 'No GPS fixes during this survey' ;
body . appendChild ( h ( 'div' , { class : 'survey-detail-gps' , text : 'GPS: ' + gpsTxt } ) ) ;
const aps = ( d . aps || [ ] ) . slice ( ) . sort ( ( a , b ) => {
const av = a . avg == null ? - Infinity : a . avg , bv = b . avg == null ? - Infinity : b . avg ;
return av - bv ;
} ) ;
if ( ! aps . length ) { body . appendChild ( h ( 'div' , { class : 'empty' , text : 'No AP samples in this survey.' } ) ) ; return ; }
body . appendChild ( table (
[
{ key : 'ssid' , label : 'SSID' , render : ( a ) => a . ssid || '(hidden)' } ,
{ key : 'bssid' , label : 'MAC' , render : ( a ) => a . bssid || '--' } ,
{ key : 'band' , label : 'Band' , render : ( a ) => a . band || '--' } ,
{ key : 'channel' , label : 'Channel' , render : ( a ) => a . channel == null ? '--' : a . channel } ,
{ key : 'min' , label : 'Min' , render : ( a ) => a . min == null ? '--' : a . min + ' dBm' } ,
{ key : 'avg' , label : 'Avg' , render : ( a ) => a . avg == null ? '--' : a . avg + ' dBm' } ,
{ key : 'max' , label : 'Max' , render : ( a ) => a . max == null ? '--' : a . max + ' dBm' } ,
{ key : 'samples' , label : 'Samples' } ,
{ key : 'first_seen' , label : 'First' , render : ( a ) => fmtShortTime ( a . first _seen ) } ,
{ key : 'last_seen' , label : 'Last' , render : ( a ) => fmtShortTime ( a . last _seen ) }
] ,
aps ) ) ;
} ) . catch ( ( ) => { body . innerHTML = '' ; body . appendChild ( h ( 'div' , { class : 'empty' , text : 'Failed to load survey detail.' } ) ) ; } ) ;
} ) ;
detailBox . appendChild ( row ) ;
} ) ;
} ) . catch ( ( ) => box . appendChild ( h ( 'div' , { class : 'empty' , text : 'Failed to load surveys.' } ) ) ) ;
}
function renderWigle ( ) {
wigleCard . appendChild ( h ( 'h2' , { text : 'WiGLE Uploads' } ) ) ;
const box = h ( 'div' ) ;
wigleCard . appendChild ( box ) ;
PagerAPI . get ( '/api/recon/wigle/files' ) . then ( ( r ) => {
const files = ( r . data && r . data . files ) || [ ] ;
box . innerHTML = '' ;
if ( ! files . length ) { box . appendChild ( h ( 'div' , { class : 'empty' , text : 'No WiGLE files yet. WiGLE logging writes a CSV per capture session.' } ) ) ; return ; }
box . appendChild ( table (
[
{ key : 'name' , label : 'File' , render : ( f ) => f . name } ,
{ key : 'mtime' , label : 'Modified' , render : ( f ) => fmtTime ( f . mtime ) } ,
{ key : 'size' , label : 'Size' , render : ( f ) => fmtBytes ( f . size ) } ,
{ key : 'rows' , label : 'AP rows' , render : ( f ) => f . rows == null ? '--' : f . rows } ,
{ key : 'warn' , label : '' , render : ( f ) => f . rows === 0 ? h ( 'span' , { class : 'survey-wigle-warn' , text : 'Header only — no data yet (needs a GPS fix)' } ) : h ( 'span' , { } ) } ,
{ key : 'dl' , label : 'Download' , render : ( f ) => iconBtn ( 'file_download' , 'Download ' + f . name , ( ) => dl ( '/api/recon/wigle/files/' + encodeURIComponent ( f . name ) ) ) }
] ,
files ) ) ;
} ) . catch ( ( ) => box . appendChild ( h ( 'div' , { class : 'empty' , text : 'Failed to load WiGLE files.' } ) ) ) ;
}
root . appendChild ( reportCard ) ;
root . appendChild ( surveyCard ) ;
root . appendChild ( wigleCard ) ;
renderScans ( ) ;
renderSurveys ( ) ;
renderWigle ( ) ;
return { destroy : ( ) => { } } ;
} ;
const LOGGING _TABS = [
{ label : 'PineAP' , hash : '#/logging' } ,