Surface NetBox IPAM records when IP has no assigned device
The /devices/by-ip/:ip endpoint previously returned 404 when an IP existed in NetBox IPAM but wasn't assigned to a device interface. Now returns the IPAM record (dns_name, status, role, description, comments) with a 200 response and ipamOnly flag. Frontend NetBoxBadge gains a new 'ipam_only' state — purple 'IP' pill badge with a dedicated tooltip showing available IPAM metadata (address, status, DNS name, FQDN, role, VRF, tenant). Distinguishes clearly from full device records while still surfacing useful infrastructure context.
This commit is contained in:
@@ -162,7 +162,9 @@ function createNetboxRouter() {
|
||||
*
|
||||
* @param {string} ip - IP address to look up
|
||||
* @response 200 - { device, ip } — device info and IP record
|
||||
* @response 404 - { error: string } — IP not found or not assigned to a device
|
||||
* @response 200 - { device: null, ip, ipamOnly: true } — IP exists in IPAM but not assigned to a device
|
||||
* @response 400 - { error: string } — missing or empty IP
|
||||
* @response 404 - { error: string } — IP not found in NetBox
|
||||
*/
|
||||
router.get('/devices/by-ip/:ip', requireTeam(), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
@@ -179,8 +181,11 @@ function createNetboxRouter() {
|
||||
if (result.ok && result.device) {
|
||||
return res.json({ device: result.device, ip: result.ip });
|
||||
}
|
||||
if (result.ok && !result.device) {
|
||||
return res.status(404).json({ error: 'IP found in NetBox but not assigned to a device.', ip: result.ip });
|
||||
if (result.ok && !result.device && result.ip) {
|
||||
// IP exists in IPAM but isn't assigned to a device interface.
|
||||
// Return the IPAM record so the frontend can surface dns_name,
|
||||
// description, status, role, and comments.
|
||||
return res.json({ device: null, ip: result.ip, ipamOnly: true });
|
||||
}
|
||||
return res.status(404).json({ error: `IP ${ip} not found in NetBox.` });
|
||||
} catch (err) {
|
||||
|
||||
@@ -17,8 +17,9 @@ import React, { useState, useEffect, useRef, useLayoutEffect, useCallback } from
|
||||
import ReactDOM from 'react-dom';
|
||||
import { Server, Loader, MapPin, Cpu, Wifi, Tag } from 'lucide-react';
|
||||
|
||||
// ⚠️ CONVENTION: Use relative API path (e.g. '/api') instead of absolute URL.
|
||||
// Express serves both API and frontend on the same origin in production.
|
||||
// ⚠️ CONVENTION: Fallback uses absolute URL with hardcoded port. Other components use
|
||||
// process.env.REACT_APP_API_BASE without a hardcoded fallback, relying on the build-time
|
||||
// env var. The fallback is acceptable for local dev but should not reference a specific port.
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
|
||||
const TOOLTIP_GAP = 8;
|
||||
@@ -57,6 +58,13 @@ const notFoundStyle = {
|
||||
color: '#64748B',
|
||||
};
|
||||
|
||||
const ipamOnlyStyle = {
|
||||
...badgeBaseStyle,
|
||||
background: 'rgba(139, 92, 246, 0.12)',
|
||||
border: '1px solid rgba(139, 92, 246, 0.4)',
|
||||
color: '#A78BFA',
|
||||
};
|
||||
|
||||
const loadingStyle = {
|
||||
...badgeBaseStyle,
|
||||
background: 'rgba(14, 165, 233, 0.06)',
|
||||
@@ -92,7 +100,7 @@ function calcPosition(anchorRect, tooltipHeight, viewportHeight) {
|
||||
// Main exported component
|
||||
// ---------------------------------------------------------------------------
|
||||
export default function NetBoxBadge({ hostName, ipAddress, cache, cardCache }) {
|
||||
const [status, setStatus] = useState('idle'); // idle | loading | found | not_found | error
|
||||
const [status, setStatus] = useState('idle'); // idle | loading | found | ipam_only | not_found | error
|
||||
const [deviceData, setDeviceData] = useState(null);
|
||||
const [tooltipOpen, setTooltipOpen] = useState(false);
|
||||
const [anchorRect, setAnchorRect] = useState(null);
|
||||
@@ -113,6 +121,9 @@ export default function NetBoxBadge({ hostName, ipAddress, cache, cardCache }) {
|
||||
setStatus('not_found');
|
||||
} else if (cached.error) {
|
||||
setStatus('error');
|
||||
} else if (cached._ipamOnly) {
|
||||
setDeviceData(cached);
|
||||
setStatus('ipam_only');
|
||||
} else {
|
||||
setDeviceData(cached);
|
||||
setStatus('found');
|
||||
@@ -159,6 +170,13 @@ export default function NetBoxBadge({ hostName, ipAddress, cache, cardCache }) {
|
||||
setDeviceData(ipData.device);
|
||||
setStatus('found');
|
||||
found = true;
|
||||
} else if (ipData.ipamOnly && ipData.ip) {
|
||||
// IP exists in IPAM but no device — surface the IPAM metadata
|
||||
const ipamRecord = { ...ipData.ip, _ipamOnly: true };
|
||||
cache.current.set(key, ipamRecord);
|
||||
setDeviceData(ipamRecord);
|
||||
setStatus('ipam_only');
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -178,7 +196,7 @@ export default function NetBoxBadge({ hostName, ipAddress, cache, cardCache }) {
|
||||
|
||||
const handleClick = useCallback((e) => {
|
||||
e.stopPropagation();
|
||||
if (status === 'found' && deviceData) {
|
||||
if ((status === 'found' || status === 'ipam_only') && deviceData) {
|
||||
setAnchorRect(badgeRef.current?.getBoundingClientRect());
|
||||
setTooltipOpen(true);
|
||||
}
|
||||
@@ -186,7 +204,7 @@ export default function NetBoxBadge({ hostName, ipAddress, cache, cardCache }) {
|
||||
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
clearTimeout(hideTimerRef.current);
|
||||
if (status === 'found' && deviceData) {
|
||||
if ((status === 'found' || status === 'ipam_only') && deviceData) {
|
||||
hideTimerRef.current = setTimeout(() => {
|
||||
setAnchorRect(badgeRef.current?.getBoundingClientRect());
|
||||
setTooltipOpen(true);
|
||||
@@ -228,6 +246,11 @@ export default function NetBoxBadge({ hostName, ipAddress, cache, cardCache }) {
|
||||
label = deviceData?.site?.name?.substring(0, 4) || 'NB';
|
||||
title = `NetBox: ${deviceData?.name || 'Device found'} — ${deviceData?.site?.name || 'Unknown site'}`;
|
||||
break;
|
||||
case 'ipam_only':
|
||||
style = ipamOnlyStyle;
|
||||
label = 'IP';
|
||||
title = `NetBox IPAM: ${deviceData?.dns_name || deviceData?.description || deviceData?.address || 'IP record'}`;
|
||||
break;
|
||||
case 'not_found':
|
||||
// Show a dim badge indicating the host isn't in NetBox
|
||||
style = notFoundStyle;
|
||||
@@ -259,15 +282,22 @@ export default function NetBoxBadge({ hostName, ipAddress, cache, cardCache }) {
|
||||
{typeof label === 'string' ? label : label}
|
||||
</span>
|
||||
{tooltipOpen && deviceData && anchorRect && ReactDOM.createPortal(
|
||||
<NetBoxTooltip
|
||||
device={deviceData}
|
||||
anchorRect={anchorRect}
|
||||
ivantiHostName={hostName}
|
||||
ivantiIp={ipAddress}
|
||||
cardCache={cardCache}
|
||||
onMouseEnter={handleTooltipEnter}
|
||||
onMouseLeave={handleTooltipLeave}
|
||||
/>,
|
||||
deviceData._ipamOnly
|
||||
? <NetBoxIpamTooltip
|
||||
ipRecord={deviceData}
|
||||
anchorRect={anchorRect}
|
||||
onMouseEnter={handleTooltipEnter}
|
||||
onMouseLeave={handleTooltipLeave}
|
||||
/>
|
||||
: <NetBoxTooltip
|
||||
device={deviceData}
|
||||
anchorRect={anchorRect}
|
||||
ivantiHostName={hostName}
|
||||
ivantiIp={ipAddress}
|
||||
cardCache={cardCache}
|
||||
onMouseEnter={handleTooltipEnter}
|
||||
onMouseLeave={handleTooltipLeave}
|
||||
/>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
@@ -479,3 +509,158 @@ function NetBoxTooltip({ device, anchorRect, ivantiHostName, ivantiIp, cardCache
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// NetBoxIpamTooltip — shown when an IP exists in IPAM but has no device
|
||||
// ---------------------------------------------------------------------------
|
||||
const IPAM_BORDER_COLOR = '#A78BFA'; // purple to distinguish from device tooltips
|
||||
|
||||
function NetBoxIpamTooltip({ ipRecord, anchorRect, onMouseEnter, onMouseLeave }) {
|
||||
const tooltipRef = useRef(null);
|
||||
const [pos, setPos] = useState({ top: 0, left: 0, placeAbove: true });
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!tooltipRef.current || !anchorRect) return;
|
||||
const rect = tooltipRef.current.getBoundingClientRect();
|
||||
const vp = window.innerHeight;
|
||||
setPos(calcPosition(anchorRect, rect.height, vp));
|
||||
}, [anchorRect, ipRecord]);
|
||||
|
||||
const tooltipStyle = {
|
||||
position: 'fixed',
|
||||
zIndex: 99999,
|
||||
top: pos.top,
|
||||
left: pos.left,
|
||||
transform: 'translateX(-50%)',
|
||||
maxWidth: 320,
|
||||
minWidth: 200,
|
||||
background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.98), rgba(51, 65, 85, 0.95))',
|
||||
border: `1.5px solid ${IPAM_BORDER_COLOR}`,
|
||||
borderRadius: '0.5rem',
|
||||
padding: '0.75rem',
|
||||
boxShadow: `0 8px 24px rgba(0, 0, 0, 0.6), 0 0 16px ${IPAM_BORDER_COLOR}33`,
|
||||
pointerEvents: 'auto',
|
||||
transition: 'opacity 0.15s ease',
|
||||
};
|
||||
|
||||
const arrowStyle = {
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderLeft: `${ARROW_SIZE}px solid transparent`,
|
||||
borderRight: `${ARROW_SIZE}px solid transparent`,
|
||||
...(pos.placeAbove
|
||||
? { bottom: -ARROW_SIZE, borderTop: `${ARROW_SIZE}px solid ${IPAM_BORDER_COLOR}`, borderBottom: 'none' }
|
||||
: { top: -ARROW_SIZE, borderBottom: `${ARROW_SIZE}px solid ${IPAM_BORDER_COLOR}`, borderTop: 'none' }),
|
||||
};
|
||||
|
||||
const LABEL = { fontSize: '0.58rem', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '0.15rem' };
|
||||
const VALUE = { fontSize: '0.72rem', color: '#E2E8F0', fontFamily: "'JetBrains Mono', monospace" };
|
||||
const BADGE = (color) => ({
|
||||
display: 'inline-block', padding: '0.12rem 0.45rem', borderRadius: '0.2rem',
|
||||
fontSize: '0.68rem', fontWeight: '600', fontFamily: "'JetBrains Mono', monospace",
|
||||
background: `${color}18`, border: `1px solid ${color}50`, color,
|
||||
});
|
||||
|
||||
const statusColors = { active: '#10B981', reserved: '#3B82F6', deprecated: '#EF4444', dhcp: '#F59E0B' };
|
||||
const statusColor = statusColors[ipRecord.status?.value] || '#94A3B8';
|
||||
|
||||
// Extract useful fields
|
||||
const address = ipRecord.display || ipRecord.address || '—';
|
||||
const dnsName = ipRecord.dns_name || null;
|
||||
const description = ipRecord.description || null;
|
||||
const role = ipRecord.role?.label || ipRecord.role?.value || null;
|
||||
const comments = ipRecord.comments || null;
|
||||
|
||||
// Parse FQDN from comments if present (format: "FQDN:xxx Alias:yyy added via...")
|
||||
let fqdn = null;
|
||||
if (comments) {
|
||||
const fqdnMatch = comments.match(/FQDN:(\S+)/i);
|
||||
if (fqdnMatch) fqdn = fqdnMatch[1];
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={tooltipRef} style={tooltipStyle} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}>
|
||||
<div style={arrowStyle} />
|
||||
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', marginBottom: '0.5rem' }}>
|
||||
<span style={{ fontSize: '0.6rem', color: IPAM_BORDER_COLOR, fontFamily: 'monospace', fontWeight: '700', textTransform: 'uppercase', letterSpacing: '0.08em' }}>
|
||||
IPAM
|
||||
</span>
|
||||
<span style={{ fontSize: '0.72rem', color: '#E2E8F0', fontFamily: "'JetBrains Mono', monospace", fontWeight: '600', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{address}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
|
||||
{/* Status */}
|
||||
{ipRecord.status && (
|
||||
<div>
|
||||
<div style={LABEL}>Status</div>
|
||||
<span style={BADGE(statusColor)}>
|
||||
{ipRecord.status.label || ipRecord.status.value || '—'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* DNS Name */}
|
||||
{dnsName && (
|
||||
<div>
|
||||
<div style={LABEL}>DNS Name</div>
|
||||
<div style={VALUE}>{dnsName}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* FQDN from comments (if different from dns_name) */}
|
||||
{fqdn && fqdn !== dnsName && (
|
||||
<div>
|
||||
<div style={LABEL}>FQDN</div>
|
||||
<div style={VALUE}>{fqdn}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{description && description !== dnsName && (
|
||||
<div>
|
||||
<div style={LABEL}>Description</div>
|
||||
<div style={VALUE}>{description}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Role */}
|
||||
{role && (
|
||||
<div>
|
||||
<div style={LABEL}>Role</div>
|
||||
<div style={VALUE}>{role}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* VRF */}
|
||||
{ipRecord.vrf && (
|
||||
<div>
|
||||
<div style={LABEL}>VRF</div>
|
||||
<div style={VALUE}>{ipRecord.vrf.name || ipRecord.vrf}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tenant */}
|
||||
{ipRecord.tenant && (
|
||||
<div>
|
||||
<div style={LABEL}>Tenant</div>
|
||||
<div style={VALUE}>{ipRecord.tenant.name || ipRecord.tenant}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Note: no device assigned */}
|
||||
<div style={{ marginTop: '0.2rem', paddingTop: '0.3rem', borderTop: '1px solid rgba(139, 92, 246, 0.2)' }}>
|
||||
<div style={{ fontSize: '0.6rem', color: '#64748B', fontStyle: 'italic' }}>
|
||||
IP registered in IPAM — no device interface assigned
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user