@@ -14,6 +14,7 @@ interface TailscaleDevice {
1414 addresses : string [ ]
1515 os : string
1616 tags ?: string [ ]
17+ user ?: string
1718 lastSeen : string
1819}
1920
@@ -27,6 +28,7 @@ interface NetworkNode {
2728 rxBytes : number
2829 connections : number
2930 tags : string [ ]
31+ user ?: string
3032 isTailscale : boolean
3133 ips ?: string [ ] // Track all IPs for merged devices
3234 incomingPorts : Set < number > // Ports this device receives traffic on
@@ -139,6 +141,11 @@ const getDeviceName = (ip: string, devices: TailscaleDevice[] = []): string => {
139141 return ip
140142}
141143
144+ // Helper function to get device data from IP
145+ const getDeviceData = ( ip : string , devices : TailscaleDevice [ ] = [ ] ) : TailscaleDevice | null => {
146+ return devices . find ( d => d . addresses . includes ( ip ) ) || null
147+ }
148+
142149
143150const NetworkView : React . FC = ( ) => {
144151 const [ selectedNode , setSelectedNode ] = useState < NetworkNode | null > ( null )
@@ -278,6 +285,13 @@ const NetworkView: React.FC = () => {
278285 const srcNodeId = srcDeviceName !== srcIP ? srcDeviceName : srcIP
279286 if ( ! nodeMap . has ( srcNodeId ) ) {
280287 const isTailscale = categorizeIP ( srcIP ) . includes ( 'tailscale' )
288+ const deviceData = getDeviceData ( srcIP , devices )
289+
290+ // Combine IP-derived tags with device tags
291+ const ipTags = categorizeIP ( srcIP )
292+ const deviceTags = deviceData ?. tags || [ ]
293+ const allTags = [ ...ipTags , ...deviceTags ] . filter ( ( tag , index , arr ) => arr . indexOf ( tag ) === index )
294+
281295 nodeMap . set ( srcNodeId , {
282296 id : srcNodeId ,
283297 ip : srcIP ,
@@ -287,7 +301,8 @@ const NetworkView: React.FC = () => {
287301 txBytes : 0 ,
288302 rxBytes : 0 ,
289303 connections : 0 ,
290- tags : categorizeIP ( srcIP ) ,
304+ tags : allTags ,
305+ user : deviceData ?. user ,
291306 isTailscale,
292307 ips : [ srcIP ] ,
293308 incomingPorts : new Set < number > ( ) ,
@@ -301,11 +316,20 @@ const NetworkView: React.FC = () => {
301316 existingNode . ips = [ ...( existingNode . ips || [ ] ) , srcIP ]
302317 // Update tags to include IPv6 if this IP is IPv6
303318 const newTags = categorizeIP ( srcIP )
304- newTags . forEach ( tag => {
319+ const deviceData = getDeviceData ( srcIP , devices )
320+ const deviceTags = deviceData ?. tags || [ ]
321+ const combinedTags = [ ...newTags , ...deviceTags ]
322+
323+ combinedTags . forEach ( tag => {
305324 if ( ! existingNode . tags . includes ( tag ) ) {
306325 existingNode . tags . push ( tag )
307326 }
308327 } )
328+
329+ // Update user if not already set
330+ if ( ! existingNode . user && deviceData ?. user ) {
331+ existingNode . user = deviceData . user
332+ }
309333 }
310334 }
311335
@@ -314,6 +338,13 @@ const NetworkView: React.FC = () => {
314338 const dstNodeId = dstDeviceName !== dstIP ? dstDeviceName : dstIP
315339 if ( ! nodeMap . has ( dstNodeId ) ) {
316340 const isTailscale = categorizeIP ( dstIP ) . includes ( 'tailscale' )
341+ const deviceData = getDeviceData ( dstIP , devices )
342+
343+ // Combine IP-derived tags with device tags
344+ const ipTags = categorizeIP ( dstIP )
345+ const deviceTags = deviceData ?. tags || [ ]
346+ const allTags = [ ...ipTags , ...deviceTags ] . filter ( ( tag , index , arr ) => arr . indexOf ( tag ) === index )
347+
317348 nodeMap . set ( dstNodeId , {
318349 id : dstNodeId ,
319350 ip : dstIP ,
@@ -323,7 +354,8 @@ const NetworkView: React.FC = () => {
323354 txBytes : 0 ,
324355 rxBytes : 0 ,
325356 connections : 0 ,
326- tags : categorizeIP ( dstIP ) ,
357+ tags : allTags ,
358+ user : deviceData ?. user ,
327359 isTailscale,
328360 ips : [ dstIP ] ,
329361 incomingPorts : new Set < number > ( ) ,
@@ -337,11 +369,20 @@ const NetworkView: React.FC = () => {
337369 existingNode . ips = [ ...( existingNode . ips || [ ] ) , dstIP ]
338370 // Update tags to include IPv6 if this IP is IPv6
339371 const newTags = categorizeIP ( dstIP )
340- newTags . forEach ( tag => {
372+ const deviceData = getDeviceData ( dstIP , devices )
373+ const deviceTags = deviceData ?. tags || [ ]
374+ const combinedTags = [ ...newTags , ...deviceTags ]
375+
376+ combinedTags . forEach ( tag => {
341377 if ( ! existingNode . tags . includes ( tag ) ) {
342378 existingNode . tags . push ( tag )
343379 }
344380 } )
381+
382+ // Update user if not already set
383+ if ( ! existingNode . user && deviceData ?. user ) {
384+ existingNode . user = deviceData . user
385+ }
345386 }
346387 }
347388
@@ -424,15 +465,56 @@ const NetworkView: React.FC = () => {
424465 // Apply filters
425466 const filteredData = useMemo ( ( ) => {
426467 let filteredNodes = nodes . filter ( node => {
427- // Search filter (search both IP and display name)
428- if ( searchQuery && ! node . ip . toLowerCase ( ) . includes ( searchQuery . toLowerCase ( ) ) &&
429- ! node . displayName . toLowerCase ( ) . includes ( searchQuery . toLowerCase ( ) ) ) {
430- return false
468+ // Enhanced search filter with tag:, user@, and ip: support
469+ if ( searchQuery ) {
470+ const query = searchQuery . toLowerCase ( ) . trim ( )
471+
472+ // Check for tag: search
473+ if ( query . startsWith ( 'tag:' ) ) {
474+ const tagSearch = query . substring ( 4 )
475+ const nodeTagsLower = node . tags . map ( tag => tag . toLowerCase ( ) . replace ( 'tag:' , '' ) )
476+ if ( ! nodeTagsLower . some ( tag => tag . includes ( tagSearch ) ) ) {
477+ return false
478+ }
479+ }
480+ // Check for ip: search
481+ else if ( query . startsWith ( 'ip:' ) ) {
482+ const ipSearch = query . substring ( 3 )
483+ const allIPs = node . ips || [ node . ip ]
484+ if ( ! allIPs . some ( ip => ip . toLowerCase ( ) . includes ( ipSearch ) ) ) {
485+ return false
486+ }
487+ }
488+ // Check for user@ search
489+ else if ( query . includes ( '@' ) && query . includes ( 'user' ) ) {
490+ const userSearch = query . replace ( 'user@' , '' ) . replace ( 'user:' , '' )
491+ if ( ! node . user || ! node . user . toLowerCase ( ) . includes ( userSearch ) ) {
492+ return false
493+ }
494+ }
495+ // Regular search (IP, display name, user, or tags)
496+ else {
497+ const allIPs = node . ips || [ node . ip ]
498+ const matchesIP = allIPs . some ( ip => ip . toLowerCase ( ) . includes ( query ) )
499+ const matchesName = node . displayName . toLowerCase ( ) . includes ( query )
500+ const matchesUser = node . user ?. toLowerCase ( ) . includes ( query ) || false
501+ const matchesTags = node . tags . some ( tag =>
502+ tag . toLowerCase ( ) . replace ( 'tag:' , '' ) . includes ( query )
503+ )
504+
505+ if ( ! matchesIP && ! matchesName && ! matchesUser && ! matchesTags ) {
506+ return false
507+ }
508+ }
431509 }
432510
433- // IP category filter
434- if ( ipCategoryFilters . size > 0 && ! node . tags . some ( tag => ipCategoryFilters . has ( tag ) ) ) {
435- return false
511+ // IP category filter (only for basic IP types, not device tags)
512+ if ( ipCategoryFilters . size > 0 ) {
513+ const ipTypes = [ 'tailscale' , 'private' , 'public' , 'derp' ]
514+ const nodeIpTypes = node . tags . filter ( tag => ipTypes . includes ( tag ) )
515+ if ( ! nodeIpTypes . some ( tag => ipCategoryFilters . has ( tag ) ) ) {
516+ return false
517+ }
436518 }
437519
438520 // IP version filter (IPv4/IPv6)
@@ -553,7 +635,12 @@ const NetworkView: React.FC = () => {
553635
554636 const dataProtocols = Array . from ( new Set ( links . map ( l => l . protocol ) ) )
555637 const dataTrafficTypes = Array . from ( new Set ( links . map ( l => l . trafficType ) ) )
556- const dataIpCategories = Array . from ( new Set ( nodes . flatMap ( n => n . tags ) . filter ( tag => tag !== 'ipv6' ) ) )
638+ // Only include basic IP types, not device tags
639+ const dataIpCategories = Array . from ( new Set (
640+ nodes . flatMap ( n => n . tags ) . filter ( tag =>
641+ [ 'tailscale' , 'private' , 'public' , 'derp' ] . includes ( tag )
642+ )
643+ ) )
557644
558645 const uniqueProtocols = Array . from ( new Set ( [ ...baseProtocols , ...dataProtocols ] ) )
559646 const uniqueTrafficTypes = Array . from ( new Set ( [ ...baseTrafficTypes , ...dataTrafficTypes ] ) )
@@ -688,11 +775,17 @@ const NetworkView: React.FC = () => {
688775 < label className = "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2" > Search</ label >
689776 < input
690777 type = "text"
691- placeholder = "Search devices or IPs ..."
778+ placeholder = "Search devices, tag:k8s, ip:100.88, user@github ..."
692779 value = { searchQuery }
693780 onChange = { ( e ) => setSearchQuery ( e . target . value ) }
694781 className = "w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400"
695782 />
783+ < div className = "mt-2 text-xs text-gray-500 dark:text-gray-400" >
784+ < div > • < code className = "bg-gray-100 dark:bg-gray-700 px-1 rounded" > tag:k8s</ code > - Find devices with specific tags</ div >
785+ < div > • < code className = "bg-gray-100 dark:bg-gray-700 px-1 rounded" > ip:100.88</ code > - Find devices by IP address</ div >
786+ < div > • < code className = "bg-gray-100 dark:bg-gray-700 px-1 rounded" > user@github</ code > - Find devices by user</ div >
787+ < div > • Regular text searches device names, IPs, and tags</ div >
788+ </ div >
696789 </ div >
697790
698791 { /* Time Range */ }
0 commit comments