Skip to content

Commit 48f72ec

Browse files
Yerazeclaude
andauthored
feat: dead nodes report with bulk delete on Security tab (#2414)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 49b8769 commit 48f72ec

File tree

2 files changed

+273
-2
lines changed

2 files changed

+273
-2
lines changed

src/components/SecurityTab.tsx

Lines changed: 169 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useAuth } from '../contexts/AuthContext';
44
import api from '../services/api';
55
import { TabType } from '../types/ui';
66
import { getHardwareModelName } from '../utils/hardwareModel';
7+
import { logger } from '../utils/logger';
78
import '../styles/SecurityTab.css';
89

910
interface 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+
5572
interface 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>

src/server/routes/securityRoutes.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import { Router, Request, Response } from 'express';
88
import { requirePermission } from '../auth/authMiddleware.js';
99
import databaseService from '../../services/database.js';
10+
import meshtasticManager from '../meshtasticManager.js';
1011
import { duplicateKeySchedulerService } from '../services/duplicateKeySchedulerService.js';
1112
import { logger } from '../../utils/logger.js';
1213

@@ -334,4 +335,107 @@ router.get('/key-mismatches', async (_req: Request, res: Response) => {
334335
}
335336
});
336337

338+
/**
339+
* GET /api/security/dead-nodes
340+
* Returns nodes not heard from in 7+ days
341+
*/
342+
router.get('/dead-nodes', async (_req: Request, res: Response) => {
343+
try {
344+
const DEAD_NODE_DAYS = 7;
345+
const cutoffSeconds = Math.floor(Date.now() / 1000) - (DEAD_NODE_DAYS * 24 * 60 * 60);
346+
347+
const allNodes = await databaseService.nodes.getAllNodes();
348+
const localNodeNum = parseInt(await databaseService.settings.getSetting('localNodeNum') || '0');
349+
350+
const deadNodes = allNodes
351+
.filter(node => {
352+
// Exclude local node
353+
if (Number(node.nodeNum) === localNodeNum) return false;
354+
// Exclude broadcast address
355+
if (Number(node.nodeNum) === 0xFFFFFFFF) return false;
356+
// Exclude ignored nodes
357+
if (node.isIgnored) return false;
358+
// Include if never heard or last heard before cutoff
359+
if (!node.lastHeard) return true;
360+
return Number(node.lastHeard) < cutoffSeconds;
361+
})
362+
.map(node => ({
363+
nodeNum: Number(node.nodeNum),
364+
nodeId: node.nodeId,
365+
longName: node.longName,
366+
shortName: node.shortName,
367+
hwModel: node.hwModel,
368+
lastHeard: node.lastHeard ? Number(node.lastHeard) : null,
369+
inDeviceDb: meshtasticManager.isNodeInDeviceDb(Number(node.nodeNum)),
370+
}))
371+
.sort((a, b) => (a.lastHeard ?? 0) - (b.lastHeard ?? 0)); // Oldest first
372+
373+
res.json({ nodes: deadNodes, count: deadNodes.length, thresholdDays: DEAD_NODE_DAYS });
374+
} catch (error) {
375+
logger.error('Error fetching dead nodes:', error);
376+
res.status(500).json({ error: 'Failed to fetch dead nodes' });
377+
}
378+
});
379+
380+
/**
381+
* POST /api/security/dead-nodes/bulk-delete
382+
* Delete multiple nodes from local DB and optionally from device NodeDB
383+
*/
384+
router.post('/dead-nodes/bulk-delete', requirePermission('security', 'write'), async (req: Request, res: Response) => {
385+
try {
386+
const { nodeNums } = req.body;
387+
const user = (req as any).user;
388+
389+
if (!Array.isArray(nodeNums) || nodeNums.length === 0) {
390+
return res.status(400).json({ error: 'nodeNums must be a non-empty array' });
391+
}
392+
393+
const results: { nodeNum: number; deleted: boolean; removedFromDevice: boolean; error?: string }[] = [];
394+
395+
for (const nodeNum of nodeNums) {
396+
try {
397+
const num = Number(nodeNum);
398+
let removedFromDevice = false;
399+
400+
// Remove from device NodeDB if present
401+
if (meshtasticManager.isNodeInDeviceDb(num)) {
402+
try {
403+
await meshtasticManager.sendRemoveNode(num);
404+
removedFromDevice = true;
405+
} catch (deviceErr) {
406+
logger.warn(`⚠️ Failed to remove node ${num} from device:`, deviceErr);
407+
}
408+
}
409+
410+
// Delete from local database
411+
await databaseService.deleteNodeAsync(num);
412+
results.push({ nodeNum: num, deleted: true, removedFromDevice });
413+
414+
logger.info(`🗑️ Dead node cleanup: deleted node ${num}${removedFromDevice ? ' (+ device)' : ''}`);
415+
} catch (err) {
416+
logger.error(`Error deleting node ${nodeNum}:`, err);
417+
results.push({ nodeNum: Number(nodeNum), deleted: false, removedFromDevice: false, error: String(err) });
418+
}
419+
}
420+
421+
const deletedCount = results.filter(r => r.deleted).length;
422+
423+
// Audit log
424+
if (user?.id) {
425+
await databaseService.auditLogAsync(
426+
user.id,
427+
'dead_nodes_cleanup',
428+
'nodes',
429+
`Bulk deleted ${deletedCount} dead node(s): ${nodeNums.join(', ')}`,
430+
req.ip || ''
431+
);
432+
}
433+
434+
res.json({ success: true, deletedCount, results });
435+
} catch (error) {
436+
logger.error('Error bulk deleting dead nodes:', error);
437+
res.status(500).json({ error: 'Failed to bulk delete nodes' });
438+
}
439+
});
440+
337441
export default router;

0 commit comments

Comments
 (0)