@@ -4,6 +4,7 @@ import { useAuth } from '../contexts/AuthContext';
44import api from '../services/api' ;
55import { TabType } from '../types/ui' ;
66import { getHardwareModelName } from '../utils/hardwareModel' ;
7+ import { logger } from '../utils/logger' ;
78import '../styles/SecurityTab.css' ;
89
910interface SecurityNode {
@@ -52,6 +53,22 @@ interface DuplicateKeyGroup {
5253 nodes : SecurityNode [ ] ;
5354}
5455
56+ interface DeadNode {
57+ nodeNum : number ;
58+ nodeId : string ;
59+ longName : string | null ;
60+ shortName : string | null ;
61+ hwModel : number | null ;
62+ lastHeard : number | null ;
63+ inDeviceDb : boolean ;
64+ }
65+
66+ interface DeadNodesResponse {
67+ nodes : DeadNode [ ] ;
68+ count : number ;
69+ thresholdDays : number ;
70+ }
71+
5572interface SecurityTabProps {
5673 onTabChange ?: ( tab : TabType ) => void ;
5774 onSelectDMNode ?: ( nodeId : string ) => void ;
@@ -69,20 +86,26 @@ export const SecurityTab: React.FC<SecurityTabProps> = ({ onTabChange, onSelectD
6986 const [ expandedNode , setExpandedNode ] = useState < number | null > ( null ) ;
7087 const [ showExportMenu , setShowExportMenu ] = useState ( false ) ;
7188 const [ mismatchEvents , setMismatchEvents ] = useState < any [ ] > ( [ ] ) ;
89+ const [ deadNodes , setDeadNodes ] = useState < DeadNode [ ] > ( [ ] ) ;
90+ const [ selectedDeadNodes , setSelectedDeadNodes ] = useState < Set < number > > ( new Set ( ) ) ;
91+ const [ isDeletingNodes , setIsDeletingNodes ] = useState ( false ) ;
7292
7393 const canWrite = hasPermission ( 'security' , 'write' ) ;
7494
7595 const fetchSecurityData = async ( ) => {
7696 try {
77- const [ issuesData , statusData , mismatchData ] = await Promise . all ( [
97+ const [ issuesData , statusData , mismatchData , deadNodesData ] = await Promise . all ( [
7898 api . get < SecurityIssuesResponse > ( '/api/security/issues' ) ,
7999 api . get < ScannerStatus > ( '/api/security/scanner/status' ) ,
80- api . get < { events : any [ ] } > ( '/api/security/key-mismatches' )
100+ api . get < { events : any [ ] } > ( '/api/security/key-mismatches' ) ,
101+ api . get < DeadNodesResponse > ( '/api/security/dead-nodes' )
81102 ] ) ;
82103
83104 setIssues ( issuesData ) ;
84105 setScannerStatus ( statusData ) ;
85106 setMismatchEvents ( mismatchData . events || [ ] ) ;
107+ setDeadNodes ( deadNodesData . nodes || [ ] ) ;
108+ setSelectedDeadNodes ( new Set ( ) ) ;
86109 setError ( null ) ;
87110 } catch ( err ) {
88111 setError ( err instanceof Error ? err . message : t ( 'security.failed_load' ) ) ;
@@ -240,6 +263,54 @@ export const SecurityTab: React.FC<SecurityTabProps> = ({ onTabChange, onSelectD
240263 }
241264 } ;
242265
266+ const formatLastHeard = ( lastHeard : number | null ) : string => {
267+ if ( ! lastHeard ) return t ( 'security.dead_nodes_never' , 'Never' ) ;
268+ const now = Math . floor ( Date . now ( ) / 1000 ) ;
269+ const diffSeconds = now - lastHeard ;
270+ const days = Math . floor ( diffSeconds / 86400 ) ;
271+ if ( days > 30 ) {
272+ const months = Math . floor ( days / 30 ) ;
273+ return t ( 'security.dead_nodes_months_ago' , '{{count}} month(s) ago' , { count : months } ) ;
274+ }
275+ return t ( 'security.dead_nodes_days_ago' , '{{count}} day(s) ago' , { count : days } ) ;
276+ } ;
277+
278+ const toggleDeadNodeSelection = ( nodeNum : number ) => {
279+ setSelectedDeadNodes ( prev => {
280+ const next = new Set ( prev ) ;
281+ if ( next . has ( nodeNum ) ) next . delete ( nodeNum ) ;
282+ else next . add ( nodeNum ) ;
283+ return next ;
284+ } ) ;
285+ } ;
286+
287+ const toggleAllDeadNodes = ( ) => {
288+ if ( selectedDeadNodes . size === deadNodes . length ) {
289+ setSelectedDeadNodes ( new Set ( ) ) ;
290+ } else {
291+ setSelectedDeadNodes ( new Set ( deadNodes . map ( n => n . nodeNum ) ) ) ;
292+ }
293+ } ;
294+
295+ const handleBulkDeleteDeadNodes = async ( ) => {
296+ if ( selectedDeadNodes . size === 0 ) return ;
297+ const confirmed = window . confirm (
298+ t ( 'security.dead_nodes_confirm_delete' , 'Are you sure you want to delete {{count}} node(s)? This cannot be undone.' , { count : selectedDeadNodes . size } )
299+ ) ;
300+ if ( ! confirmed ) return ;
301+
302+ setIsDeletingNodes ( true ) ;
303+ try {
304+ await api . post ( '/api/security/dead-nodes/bulk-delete' , { nodeNums : Array . from ( selectedDeadNodes ) } ) ;
305+ setSelectedDeadNodes ( new Set ( ) ) ;
306+ await fetchSecurityData ( ) ;
307+ } catch ( err ) {
308+ logger . error ( 'Error bulk deleting dead nodes:' , err ) ;
309+ } finally {
310+ setIsDeletingNodes ( false ) ;
311+ }
312+ } ;
313+
243314 if ( loading ) {
244315 return (
245316 < div className = "security-tab" >
@@ -705,6 +776,102 @@ export const SecurityTab: React.FC<SecurityTabProps> = ({ onTabChange, onSelectD
705776 </ div >
706777 </ div >
707778 ) }
779+ { /* Dead Nodes Section */ }
780+ < div className = "issues-section" >
781+ < h3 > { t ( 'security.dead_nodes_title' , 'Dead Nodes' ) } ({ deadNodes . length } )</ h3 >
782+ { deadNodes . length === 0 ? (
783+ < div className = "no-issues" >
784+ < p > { t ( 'security.dead_nodes_empty' , 'No dead nodes found. All nodes have been heard from within the last 7 days.' ) } </ p >
785+ </ div >
786+ ) : (
787+ < >
788+ < div style = { { display : 'flex' , gap : '0.75rem' , alignItems : 'center' , marginBottom : '1rem' , flexWrap : 'wrap' } } >
789+ < button
790+ onClick = { toggleAllDeadNodes }
791+ style = { {
792+ padding : '0.4rem 0.75rem' ,
793+ fontSize : '0.85rem' ,
794+ backgroundColor : 'var(--ctp-surface1)' ,
795+ color : 'var(--ctp-text)' ,
796+ border : 'none' ,
797+ borderRadius : '4px' ,
798+ cursor : 'pointer'
799+ } }
800+ >
801+ { selectedDeadNodes . size === deadNodes . length
802+ ? t ( 'security.dead_nodes_deselect_all' , 'Deselect All' )
803+ : t ( 'security.dead_nodes_select_all' , 'Select All' ) }
804+ </ button >
805+ { selectedDeadNodes . size > 0 && canWrite && (
806+ < button
807+ onClick = { handleBulkDeleteDeadNodes }
808+ disabled = { isDeletingNodes }
809+ style = { {
810+ padding : '0.4rem 0.75rem' ,
811+ fontSize : '0.85rem' ,
812+ backgroundColor : 'var(--ctp-red)' ,
813+ color : 'var(--ctp-base)' ,
814+ border : 'none' ,
815+ borderRadius : '4px' ,
816+ cursor : isDeletingNodes ? 'not-allowed' : 'pointer' ,
817+ opacity : isDeletingNodes ? 0.6 : 1
818+ } }
819+ >
820+ { isDeletingNodes
821+ ? t ( 'security.dead_nodes_deleting' , 'Deleting...' )
822+ : t ( 'security.dead_nodes_delete_button' , 'Delete {{count}} node(s)' , { count : selectedDeadNodes . size } ) }
823+ </ button >
824+ ) }
825+ </ div >
826+ < table className = "top-broadcasters-table" >
827+ < thead >
828+ < tr >
829+ < th style = { { width : '30px' } } > </ th >
830+ < th > { t ( 'security.dead_nodes_name' , 'Name' ) } </ th >
831+ < th > { t ( 'security.dead_nodes_id' , 'ID' ) } </ th >
832+ < th > { t ( 'security.dead_nodes_hardware' , 'Hardware' ) } </ th >
833+ < th > { t ( 'security.dead_nodes_last_heard' , 'Last Heard' ) } </ th >
834+ < th > { t ( 'security.dead_nodes_location' , 'Location' ) } </ th >
835+ </ tr >
836+ </ thead >
837+ < tbody >
838+ { deadNodes . map ( node => (
839+ < tr key = { node . nodeNum } style = { { opacity : selectedDeadNodes . has ( node . nodeNum ) ? 1 : 0.8 } } >
840+ < td >
841+ < input
842+ type = "checkbox"
843+ checked = { selectedDeadNodes . has ( node . nodeNum ) }
844+ onChange = { ( ) => toggleDeadNodeSelection ( node . nodeNum ) }
845+ />
846+ </ td >
847+ < td >
848+ { node . longName || node . shortName || < span style = { { color : 'var(--ctp-subtext0)' , fontStyle : 'italic' } } > Unknown</ span > }
849+ { node . shortName && node . longName && (
850+ < span style = { { color : 'var(--ctp-subtext0)' , marginLeft : '0.5rem' , fontSize : '0.8rem' } } > ({ node . shortName } )</ span >
851+ ) }
852+ </ td >
853+ < td style = { { fontFamily : 'monospace' , fontSize : '0.85rem' } } > { node . nodeId } </ td >
854+ < td > { node . hwModel != null ? getHardwareModelName ( node . hwModel ) : '-' } </ td >
855+ < td > { formatLastHeard ( node . lastHeard ) } </ td >
856+ < td >
857+ { node . inDeviceDb ? (
858+ < span title = { t ( 'security.dead_nodes_in_both' , 'In both local and device database' ) } style = { { color : 'var(--ctp-yellow)' } } >
859+ 📡 { t ( 'security.dead_nodes_local_and_device' , 'Local + Device' ) }
860+ </ span >
861+ ) : (
862+ < span title = { t ( 'security.dead_nodes_local_only' , 'Only in local database' ) } style = { { color : 'var(--ctp-subtext0)' } } >
863+ 💾 { t ( 'security.dead_nodes_local_only_short' , 'Local Only' ) }
864+ </ span >
865+ ) }
866+ </ td >
867+ </ tr >
868+ ) ) }
869+ </ tbody >
870+ </ table >
871+ </ >
872+ ) }
873+ </ div >
874+
708875 { /* Key Mismatch Events Section */ }
709876 < div className = "issues-section" >
710877 < h3 > { t ( 'security.key_mismatch_title' ) } </ h3 >
0 commit comments