ONOS-6327: Implement details panel for host view.
ONOS-6326: Add friendly names to hosts.
- PLENTY more YakShaving:
  * some cleanup of the device view handler
  * introduce navPath field to PropertyPanel
  * introduce "-" name annotation to represent "use default"
  * (and more...)

Change-Id: I2afc0f1f29c726b90e97e492527edde2d1345ece
diff --git a/core/api/src/main/java/org/onosproject/net/config/basics/BasicHostConfig.java b/core/api/src/main/java/org/onosproject/net/config/basics/BasicHostConfig.java
index 7b870b4..1a8db33 100644
--- a/core/api/src/main/java/org/onosproject/net/config/basics/BasicHostConfig.java
+++ b/core/api/src/main/java/org/onosproject/net/config/basics/BasicHostConfig.java
@@ -31,6 +31,7 @@
 
     private static final String IPS = "ips";
     private static final String LOCATIONS = "locations";
+    private static final String DASH = "-";
 
     @Override
     public boolean isValid() {
@@ -41,6 +42,17 @@
                 GRID_Y, GRID_Y, UI_TYPE, RACK_ADDRESS, OWNER, IPS, LOCATIONS);
     }
 
+    @Override
+    public String name() {
+        // NOTE:
+        // We don't want to default to host ID if friendly name is not set;
+        // (it isn't particularly friendly, e.g. "00:00:00:00:00:01/None").
+        // We'd prefer to clear the annotation, but if we pass null, then the
+        // value won't get set (see BasicElementOperator). So, instead we will
+        // return a DASH to signify "use the default friendly name".
+        return get(NAME, DASH);
+    }
+
     /**
      * Returns the location of the host.
      *
diff --git a/core/api/src/main/java/org/onosproject/ui/topo/PropertyPanel.java b/core/api/src/main/java/org/onosproject/ui/topo/PropertyPanel.java
index 05565f7..b614ef4 100644
--- a/core/api/src/main/java/org/onosproject/ui/topo/PropertyPanel.java
+++ b/core/api/src/main/java/org/onosproject/ui/topo/PropertyPanel.java
@@ -33,6 +33,7 @@
     private String title;
     private String typeId;
     private String id;
+    private String navPath;
     private List<Prop> properties = new ArrayList<>();
     private List<ButtonId> buttons = new ArrayList<>();
 
@@ -40,7 +41,7 @@
      * Constructs a property panel model with the given title and
      * type identifier (icon to display).
      *
-     * @param title title text
+     * @param title  title text
      * @param typeId type (icon) ID
      */
     public PropertyPanel(String title, String typeId) {
@@ -67,6 +68,20 @@
     }
 
     /**
+     * Adds a navigation path field to the panel data, to be included in
+     * the returned JSON data to the client. This is typically used to
+     * configure the topology view with the appropriate navigation path for
+     * a hot-link to some other view.
+     *
+     * @param navPath the navigation path
+     * @return self for chaining
+     */
+    public PropertyPanel navPath(String navPath) {
+        this.navPath = navPath;
+        return this;
+    }
+
+    /**
      * Adds an ID field to the panel data, to be included in
      * the returned JSON data to the client.
      *
@@ -81,7 +96,7 @@
     /**
      * Adds a property to the panel data.
      *
-     * @param key property key
+     * @param key   property key
      * @param value property value
      * @return self, for chaining
      */
@@ -93,7 +108,7 @@
     /**
      * Adds a property to the panel data, using a decimal formatter.
      *
-     * @param key property key
+     * @param key   property key
      * @param value property value
      * @return self, for chaining
      */
@@ -105,7 +120,7 @@
     /**
      * Adds a property to the panel data, using a decimal formatter.
      *
-     * @param key property key
+     * @param key   property key
      * @param value property value
      * @return self, for chaining
      */
@@ -119,7 +134,7 @@
      * {@link Object#toString toString()} method is used to convert the
      * value to a string.
      *
-     * @param key property key
+     * @param key   property key
      * @param value property value
      * @return self, for chaining
      */
@@ -134,8 +149,8 @@
      * value to a string, from which the characters defined in the given
      * regular expression string are stripped.
      *
-     * @param key property key
-     * @param value property value
+     * @param key     property key
+     * @param value   property value
      * @param reStrip regexp characters to strip from value string
      * @return self, for chaining
      */
@@ -174,6 +189,15 @@
     }
 
     /**
+     * Returns the navigation path.
+     *
+     * @return the navigation path
+     */
+    public String navPath() {
+        return navPath;
+    }
+
+    /**
      * Returns the internal ID.
      *
      * @return the ID
@@ -235,7 +259,7 @@
     public PropertyPanel removeProps(String... keys) {
         Set<String> forRemoval = Sets.newHashSet(keys);
         List<Prop> toKeep = new ArrayList<>();
-        for (Prop p: properties) {
+        for (Prop p : properties) {
             if (!forRemoval.contains(p.key())) {
                 toKeep.add(p);
             }
@@ -274,7 +298,7 @@
     public PropertyPanel removeButtons(ButtonId... descriptors) {
         Set<ButtonId> forRemoval = Sets.newHashSet(descriptors);
         List<ButtonId> toKeep = new ArrayList<>();
-        for (ButtonId bd: buttons) {
+        for (ButtonId bd : buttons) {
             if (!forRemoval.contains(bd)) {
                 toKeep.add(bd);
             }
@@ -306,7 +330,7 @@
         /**
          * Constructs a property data value.
          *
-         * @param key property key
+         * @param key   property key
          * @param value property value
          */
         public Prop(String key, String value) {
diff --git a/core/api/src/main/java/org/onosproject/ui/topo/TopoJson.java b/core/api/src/main/java/org/onosproject/ui/topo/TopoJson.java
index 653b0f9..13e131d 100644
--- a/core/api/src/main/java/org/onosproject/ui/topo/TopoJson.java
+++ b/core/api/src/main/java/org/onosproject/ui/topo/TopoJson.java
@@ -47,6 +47,7 @@
 
     static final String TITLE = "title";
     static final String TYPE = "type";
+    static final String NAV_PATH = "navPath";
     static final String PROP_ORDER = "propOrder";
     static final String PROPS = "props";
     static final String BUTTONS = "buttons";
@@ -63,7 +64,8 @@
     }
 
     // non-instantiable
-    private TopoJson() { }
+    private TopoJson() {
+    }
 
     /**
      * Returns a formatted message ready to send to the topology view
@@ -179,6 +181,10 @@
                 .put(TYPE, pp.typeId())
                 .put(ID, pp.id());
 
+        if (pp.navPath() != null) {
+            result.put(NAV_PATH, pp.navPath());
+        }
+
         ObjectNode pnode = objectNode();
         ArrayNode porder = arrayNode();
         for (PropertyPanel.Prop p : pp.properties()) {
diff --git a/core/api/src/test/java/org/onosproject/net/ConnectPointTest.java b/core/api/src/test/java/org/onosproject/net/ConnectPointTest.java
index 74b162e..51835fe 100644
--- a/core/api/src/test/java/org/onosproject/net/ConnectPointTest.java
+++ b/core/api/src/test/java/org/onosproject/net/ConnectPointTest.java
@@ -121,12 +121,15 @@
         switch (r) {
             case BEFORE:
                 assertTrue("Bad before", cpA.compareTo(cpB) < 0);
+                assertTrue("Bad before", cpB.compareTo(cpA) > 0);
                 break;
             case SAME_AS:
                 assertTrue("Bad same_as", cpA.compareTo(cpB) == 0);
+                assertTrue("Bad same_as", cpB.compareTo(cpA) == 0);
                 break;
             case AFTER:
                 assertTrue("Bad after", cpA.compareTo(cpB) > 0);
+                assertTrue("Bad after", cpB.compareTo(cpA) < 0);
                 break;
             default:
                 fail("Bad relation");
diff --git a/web/gui/src/main/java/org/onosproject/ui/impl/HostViewMessageHandler.java b/web/gui/src/main/java/org/onosproject/ui/impl/HostViewMessageHandler.java
index 169b89f..2475937 100644
--- a/web/gui/src/main/java/org/onosproject/ui/impl/HostViewMessageHandler.java
+++ b/web/gui/src/main/java/org/onosproject/ui/impl/HostViewMessageHandler.java
@@ -23,6 +23,8 @@
 import org.onosproject.net.Host;
 import org.onosproject.net.HostId;
 import org.onosproject.net.HostLocation;
+import org.onosproject.net.config.NetworkConfigService;
+import org.onosproject.net.config.basics.BasicHostConfig;
 import org.onosproject.net.host.HostService;
 import org.onosproject.ui.RequestHandler;
 import org.onosproject.ui.UiMessageHandler;
@@ -36,9 +38,11 @@
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.Iterator;
 import java.util.List;
 import java.util.Set;
 
+import static com.google.common.base.Strings.emptyToNull;
 import static com.google.common.base.Strings.isNullOrEmpty;
 import static org.onosproject.net.HostId.hostId;
 
@@ -63,11 +67,13 @@
     private static final String ID = "id";
     private static final String MAC = "mac";
     private static final String VLAN = "vlan";
+    private static final String IP = "ip";
     private static final String IPS = "ips";
     private static final String LOCATION = "location";
     private static final String LOCATIONS = "locations";
     private static final String CONFIGURED = "configured";
 
+    private static final String DASH = "-";
 
     private static final String HOST_ICON_PREFIX = "hostIcon_";
 
@@ -92,10 +98,20 @@
                 (isNullOrEmpty(hostType) ? "endstation" : hostType);
     }
 
+    // Returns the first of the given set of IP addresses as a string.
+    private String ip(Set<IpAddress> ipAddresses) {
+        Iterator<IpAddress> it = ipAddresses.iterator();
+        return it.hasNext() ? it.next().toString() : "unknown";
+    }
+
+    private boolean useDefaultName(String nameAnnotated) {
+        return isNullOrEmpty(nameAnnotated) || DASH.equals(nameAnnotated);
+    }
+
     // returns the "friendly name" for the host
     private String getHostName(Host host) {
-        // TODO: acutally use the name field (not just the ID)
-        return host.id().toString();
+        String name = host.annotations().value(AnnotationKeys.NAME);
+        return useDefaultName(name) ? ip(host.ipAddresses()) : name;
     }
 
     // handler for host table requests
@@ -151,7 +167,7 @@
             public String format(Object value) {
                 Set<IpAddress> ips = (Set<IpAddress>) value;
                 if (ips.isEmpty()) {
-                    return "(No IP Addresses for this host)";
+                    return "(No IP Addresses)";
                 }
                 StringBuilder sb = new StringBuilder();
                 for (IpAddress ip : ips) {
@@ -187,6 +203,7 @@
             data.put(TYPE_IID, getTypeIconId(host))
                     .put(NAME, getHostName(host))
                     .put(ID, hostId.toString())
+                    .put(IP, ip(host.ipAddresses()))
                     .put(MAC, host.mac().toString())
                     .put(VLAN, host.vlan().toString())
                     .put(CONFIGURED, host.configured())
@@ -216,17 +233,25 @@
     }
 
     private final class NameChangeHandler extends RequestHandler {
-        public NameChangeHandler() {
+        private NameChangeHandler() {
             super(HOST_NAME_CHANGE_REQ);
         }
 
         @Override
         public void process(ObjectNode payload) {
-            // TODO:
+            HostId hostId = hostId(string(payload, ID, ""));
+            String name = emptyToNull(string(payload, NAME, null));
+            log.debug("Name change request: {} -- '{}'", hostId, name);
 
-            ObjectNode root = objectNode();
+            NetworkConfigService service = get(NetworkConfigService.class);
+            BasicHostConfig cfg =
+                    service.addConfig(hostId, BasicHostConfig.class);
 
-            sendMessage(HOST_NAME_CHANGE_RESP, root);
+            // Name attribute missing from the payload (or empty string)
+            // means that the friendly name should be unset.
+            cfg.name(name);
+            cfg.apply();
+            sendMessage(HOST_NAME_CHANGE_RESP, payload);
         }
     }
 }
diff --git a/web/gui/src/main/java/org/onosproject/ui/impl/TopologyViewMessageHandlerBase.java b/web/gui/src/main/java/org/onosproject/ui/impl/TopologyViewMessageHandlerBase.java
index 83a23ff..456d81a 100644
--- a/web/gui/src/main/java/org/onosproject/ui/impl/TopologyViewMessageHandlerBase.java
+++ b/web/gui/src/main/java/org/onosproject/ui/impl/TopologyViewMessageHandlerBase.java
@@ -74,6 +74,11 @@
 public abstract class TopologyViewMessageHandlerBase extends UiMessageHandler {
 
     private static final String NO_GEO_VALUE = "0.0";
+    private static final String DASH = "-";
+
+    // nav paths are the view names for hot-link navigation from topo view...
+    private static final String DEVICE_NAV_PATH = "device";
+    private static final String HOST_NAV_PATH = "host";
 
     // default to an "add" event...
     private static final DefaultHashMap<ClusterEvent.Type, String> CLUSTER_EVENT =
@@ -139,7 +144,7 @@
         version = ver.replace(".SNAPSHOT", "*").replaceFirst("~.*$", "");
     }
 
-    // Returns the specified set of IP addresses as a string.
+    // Returns the first of the given set of IP addresses as a string.
     private String ip(Set<IpAddress> ipAddresses) {
         Iterator<IpAddress> it = ipAddresses.iterator();
         return it.hasNext() ? it.next().toString() : "unknown";
@@ -210,6 +215,8 @@
         String uiType = device.annotations().value(AnnotationKeys.UI_TYPE);
         String devType = uiType != null ? uiType :
                 device.type().toString().toLowerCase();
+        String name = device.annotations().value(AnnotationKeys.NAME);
+        name = isNullOrEmpty(name) ? device.id().toString() : name;
 
         ObjectNode payload = objectNode()
                 .put("id", device.id().toString())
@@ -217,15 +224,7 @@
                 .put("online", services.device().isAvailable(device.id()))
                 .put("master", master(device.id()));
 
-        // Generate labels: id, chassis id, no-label, optional-name
-        String name = device.annotations().value(AnnotationKeys.NAME);
-        ArrayNode labels = arrayNode();
-        labels.add("");
-        labels.add(isNullOrEmpty(name) ? device.id().toString() : name);
-        labels.add(device.id().toString());
-
-        // Add labels, props and stuff the payload into envelope.
-        payload.set("labels", labels);
+        payload.set("labels", labels("", name, device.id().toString()));
         payload.set("props", props(device.annotations()));
         addGeoLocation(device, payload);
         addMetaUi(device.id().toString(), payload);
@@ -256,18 +255,19 @@
         Host host = event.subject();
         Host prevHost = event.prevSubject();
         String hostType = host.annotations().value(AnnotationKeys.UI_TYPE);
+        String ip = ip(host.ipAddresses());
 
         ObjectNode payload = objectNode()
                 .put("id", host.id().toString())
                 .put("type", isNullOrEmpty(hostType) ? "endstation" : hostType)
                 .put("ingress", compactLinkString(edgeLink(host, true)))
                 .put("egress", compactLinkString(edgeLink(host, false)));
+
         payload.set("cp", hostConnect(host.location()));
         if (prevHost != null && prevHost.location() != null) {
             payload.set("prevCp", hostConnect(prevHost.location()));
         }
-        payload.set("labels", labels(ip(host.ipAddresses()),
-                                     host.mac().toString()));
+        payload.set("labels", labels(nameForHost(host), ip, host.mac().toString()));
         payload.set("props", props(host.annotations()));
         addGeoLocation(host, payload);
         addMetaUi(host.id().toString(), payload);
@@ -378,6 +378,7 @@
         String typeId = device.type().toString().toLowerCase();
 
         return new PropertyPanel(title, typeId)
+                .navPath(DEVICE_NAV_PATH)
                 .id(deviceId.toString())
 
                 .addProp(Properties.URI, deviceId.toString())
@@ -435,18 +436,25 @@
         return count;
     }
 
+    private boolean useDefaultName(String annotName) {
+        return isNullOrEmpty(annotName) || DASH.equals(annotName);
+    }
+
+    private String nameForHost(Host host) {
+        String name = host.annotations().value(AnnotationKeys.NAME);
+        return useDefaultName(name) ? ip(host.ipAddresses()) : name;
+    }
+
     // Returns host details response.
     protected PropertyPanel hostDetails(HostId hostId) {
         Host host = services.host().getHost(hostId);
         Annotations annot = host.annotations();
         String type = annot.value(AnnotationKeys.TYPE);
-        String name = annot.value(AnnotationKeys.NAME);
         String vlan = host.vlan().toString();
-
-        String title = isNullOrEmpty(name) ? hostId.toString() : name;
         String typeId = isNullOrEmpty(type) ? "endstation" : type;
 
-        return new PropertyPanel(title, typeId)
+        return new PropertyPanel(nameForHost(host), typeId)
+                .navPath(HOST_NAV_PATH)
                 .id(hostId.toString())
                 .addProp(Properties.MAC, host.mac())
                 .addProp(Properties.IP, host.ipAddresses(), "[\\[\\]]")
diff --git a/web/gui/src/main/webapp/app/common.css b/web/gui/src/main/webapp/app/common.css
index b8952c3..7aed6ef 100644
--- a/web/gui/src/main/webapp/app/common.css
+++ b/web/gui/src/main/webapp/app/common.css
@@ -22,3 +22,18 @@
     cursor: pointer;
 }
 
+.light .editable {
+    border-bottom: 1px dashed #ca504b;
+}
+
+.dark .editable {
+    border-bottom: 1px dashed #df4f4a;
+}
+
+.light svg.embeddedIcon .icon .glyph {
+    fill: #0071bd;
+}
+
+.dark svg.embeddedIcon .icon .glyph {
+    fill: #375b7f;
+}
diff --git a/web/gui/src/main/webapp/app/view/device/device-theme.css b/web/gui/src/main/webapp/app/view/device/device-theme.css
index 3aa67d2..f7e2873 100644
--- a/web/gui/src/main/webapp/app/view/device/device-theme.css
+++ b/web/gui/src/main/webapp/app/view/device/device-theme.css
@@ -18,15 +18,6 @@
  ONOS GUI -- Device View (theme) -- CSS file
  */
 
-
-.light .dev-icon svg.embeddedIcon .icon .glyph {
-    fill: #0071bd;
-}
-
-.light #device-details-panel .editable {
-    border-bottom: 1px dashed #ca504b;
-}
-
 .light #device-details-panel .bottom th {
     background-color: #e5e5e6;
 }
@@ -38,12 +29,3 @@
     background-color: #f4f4f4;
 }
 
-/* ========== DARK Theme ========== */
-
-.dark .dev-icon svg.embeddedIcon .icon .glyph {
-    fill: #375b7f;
-}
-
-.dark #device-details-panel .editable {
-    border-bottom: 1px dashed #df4f4a;
-}
diff --git a/web/gui/src/main/webapp/app/view/host/host.css b/web/gui/src/main/webapp/app/view/host/host.css
index 377918a..1aa6f86 100644
--- a/web/gui/src/main/webapp/app/view/host/host.css
+++ b/web/gui/src/main/webapp/app/view/host/host.css
@@ -58,7 +58,7 @@
 
 #host-details-panel h2 input {
     font-size: 0.90em;
-    width: 106%;
+    width: 112%;
 }
 
 #host-details-panel .top-tables {
diff --git a/web/gui/src/main/webapp/app/view/host/host.js b/web/gui/src/main/webapp/app/view/host/host.js
index ee23f00..4e8153c 100644
--- a/web/gui/src/main/webapp/app/view/host/host.js
+++ b/web/gui/src/main/webapp/app/view/host/host.js
@@ -29,7 +29,6 @@
         pStartY,
         pHeight,
         top,
-        bottom,
         iconDiv,
         wSize,
         editingName = false,
@@ -37,15 +36,19 @@
 
     // constants
     var topPdg = 28,
-        ctnrPdg = 24,
-        scrollSize = 17,
-
         pName = 'host-details-panel',
         detailsReq = 'hostDetailsRequest',
         detailsResp = 'hostDetailsResponse',
         nameChangeReq = 'hostNameChangeRequest',
         nameChangeResp = 'hostNameChangeResponse';
 
+    var propOrder = [
+            'id', 'ip', 'mac', 'vlan', 'configured', 'location'
+        ],
+        friendlyProps = [
+            'Host ID', 'IP Address', 'MAC Address', 'VLAN',
+            'Configured', 'Location'
+        ];
 
     function closePanel() {
         if (detailsPanel.isVisible()) {
@@ -71,12 +74,13 @@
     function editNameSave() {
         var nameH2 = top.select('h2'),
             id = $scope.panelData.id,
+            ip = $scope.panelData.ip,
             val,
             newVal;
 
         if (editingName) {
             val = nameH2.select('input').property('value').trim();
-            newVal = val || id;
+            newVal = val || ip;
 
             exitEditMode(nameH2, newVal);
             $scope.panelData.name = newVal;
@@ -115,7 +119,7 @@
     }
 
     function setUpPanel() {
-        var container, closeBtn, tblDiv;
+        var container, closeBtn;
         detailsPanel.empty();
 
         container = detailsPanel.append('div').classed('container', true);
@@ -126,22 +130,29 @@
         iconDiv = top.append('div').classed('host-icon', true);
         top.append('h2').classed('editable clickable', true).on('click', editName);
 
-        // tblDiv = top.append('div').classed('top-tables', true);
-        // tblDiv.append('div').classed('left', true).append('table');
-        // tblDiv.append('div').classed('right', true).append('table');
-
+        top.append('div').classed('top-tables', true);
         top.append('hr');
+    }
 
-        // bottom = container.append('div').classed('bottom', true);
-        // bottom.append('h2').classed('ports-title', true).text('Ports');
-        // bottom.append('table');
+    function addProp(tbody, index, value) {
+        var tr = tbody.append('tr');
+
+        function addCell(cls, txt) {
+            tr.append('td').attr('class', cls).text(txt);
+        }
+        addCell('label', friendlyProps[index] + ' :');
+        addCell('value', value);
     }
 
     function populateTop(details) {
+        var tab = top.select('.top-tables').append('tbody');
+
         is.loadEmbeddedIcon(iconDiv, details._iconid_type, 40);
         top.select('h2').text(details.name);
 
-        // TODO: still need to add host properties (one per line)
+        propOrder.forEach(function (prop, i) {
+            addProp(tab, i, details[prop]);
+        });
     }
 
     function populateDetails(details) {
@@ -149,7 +160,7 @@
         populateTop(details);
         detailsPanel.height(pHeight);
         // configure width based on content.. for now hardcoded
-        detailsPanel.width(600);
+        detailsPanel.width(400);
     }
 
     function respDetailsCb(data) {
diff --git a/web/gui/src/main/webapp/app/view/topo/topo.js b/web/gui/src/main/webapp/app/view/topo/topo.js
index 8acebcc..29f149f 100644
--- a/web/gui/src/main/webapp/app/view/topo/topo.js
+++ b/web/gui/src/main/webapp/app/view/topo/topo.js
@@ -61,6 +61,7 @@
             Z: [tos.toggleOblique, 'Toggle oblique view (Experimental)'],
             N: [fltr.clickAction, 'Cycle node layers'],
             L: [tfs.cycleDeviceLabels, 'Cycle device labels'],
+            'shift-L': [tfs.cycleHostLabels, 'Cycle host labels'],
             U: [tfs.unpin, 'Unpin node (hover mouse over)'],
             R: [resetZoom, 'Reset pan / zoom'],
             dot: [ttbs.toggleToolbar, 'Toggle Toolbar'],
@@ -83,7 +84,7 @@
 
             _helpFormat: [
                 ['I', 'O', 'D', 'H', 'M', 'P', 'dash', 'B', 'G', 'S' ],
-                ['X', 'Z', 'N', 'L', 'U', 'R', '-', 'E', '-', 'dot'],
+                ['X', 'Z', 'N', 'L', 'shift-L', 'U', 'R', '-', 'E', '-', 'dot'],
                 []   // this column reserved for overlay actions
             ]
         };
@@ -494,6 +495,7 @@
         toggleMap(prefsState.bg);
         toggleSprites(prefsState.spr);
         t3s.setDevLabIndex(prefsState.dlbls);
+        t3s.setHostLabIndex(prefsState.hlbls);
         flash.enable(true);
     }
 
diff --git a/web/gui/src/main/webapp/app/view/topo/topoD3.js b/web/gui/src/main/webapp/app/view/topo/topoD3.js
index e45f491..3d232ee 100644
--- a/web/gui/src/main/webapp/app/view/topo/topoD3.js
+++ b/web/gui/src/main/webapp/app/view/topo/topoD3.js
@@ -139,6 +139,22 @@
         ps.setPrefs('topo_prefs', p);
     }
 
+    function incHostLabIndex() {
+        setHostLabIndex(hostLabelIndex+1);
+        switch(hostLabelIndex) {
+            case 0: return 'Show friendly host labels';
+            case 1: return 'Show host IP Addresses';
+            case 2: return 'Show host MAC Addresses';
+        }
+    }
+
+    function setHostLabIndex(mode) {
+        hostLabelIndex = mode % 3;
+        var p = ps.getPrefs('topo_prefs', ttbs.defaultPrefs);
+        p.hlbls = hostLabelIndex;
+        ps.setPrefs('topo_prefs', p);
+    }
+
     function hostLabel(d) {
         var idx = (hostLabelIndex < d.labels.length) ? hostLabelIndex : 0;
         return d.labels[idx];
@@ -617,6 +633,8 @@
 
                 incDevLabIndex: incDevLabIndex,
                 setDevLabIndex: setDevLabIndex,
+                incHostLabIndex: incHostLabIndex,
+                setHostLabIndex: setHostLabIndex,
                 hostLabel: hostLabel,
                 deviceLabel: deviceLabel,
                 trimLabel: trimLabel,
diff --git a/web/gui/src/main/webapp/app/view/topo/topoForce.js b/web/gui/src/main/webapp/app/view/topo/topoForce.js
index 9b9a29c..f38fa56 100644
--- a/web/gui/src/main/webapp/app/view/topo/topoForce.js
+++ b/web/gui/src/main/webapp/app/view/topo/topoForce.js
@@ -516,6 +516,13 @@
         });
     }
 
+    function cycleHostLabels() {
+        flash.flash(td3.incHostLabIndex());
+        tms.findHosts().forEach(function (d) {
+            td3.updateHostLabel(d);
+        });
+    }
+
     function unpin() {
         var hov = tss.hovered();
         if (hov) {
@@ -1240,6 +1247,7 @@
                 togglePorts: tls.togglePorts,
                 toggleOffline: toggleOffline,
                 cycleDeviceLabels: cycleDeviceLabels,
+                cycleHostLabels: cycleHostLabels,
                 unpin: unpin,
                 showMastership: showMastership,
                 showBadLinks: showBadLinks,
diff --git a/web/gui/src/main/webapp/app/view/topo/topoModel.js b/web/gui/src/main/webapp/app/view/topo/topoModel.js
index 3236ae5..e841907 100644
--- a/web/gui/src/main/webapp/app/view/topo/topoModel.js
+++ b/web/gui/src/main/webapp/app/view/topo/topoModel.js
@@ -366,6 +366,16 @@
         return a;
     }
 
+    function findHosts() {
+        var hosts = [];
+        nodes.forEach(function (d) {
+            if (d.class === 'host') {
+                hosts.push(d);
+            }
+        });
+        return hosts;
+    }
+
     function findAttachedHosts(devId) {
         var hosts = [];
         nodes.forEach(function (d) {
@@ -453,6 +463,7 @@
                 findLink: findLink,
                 findLinkById: findLinkById,
                 findDevices: findDevices,
+                findHosts: findHosts,
                 findAttachedHosts: findAttachedHosts,
                 findAttachedLinks: findAttachedLinks,
                 findBadLinks: findBadLinks
diff --git a/web/gui/src/main/webapp/app/view/topo/topoPanel.js b/web/gui/src/main/webapp/app/view/topo/topoPanel.js
index 100cd96..8f8eef5 100644
--- a/web/gui/src/main/webapp/app/view/topo/topoPanel.js
+++ b/web/gui/src/main/webapp/app/view/topo/topoPanel.js
@@ -35,8 +35,7 @@
         sumMax = 226,           // summary panel max height
         padTop = 16,            // summary panel padding below masthead
         padding = 16,           // panel internal padding
-        padFudge = padTop + 2 * padding,
-        devPath = 'device';
+        padFudge = padTop + 2 * padding;
 
     // internal state
     var useDetails = true,      // should we show details if we have 'em?
@@ -230,10 +229,9 @@
     // === -----------------------------------------------------
     //  Functions for populating the detail panel
 
-    var isDevice = {
-        switch: 1,
-        roadm: 1,
-        otn:1
+    var navPathIdKey = {
+        device: 'devId',
+        host: 'hostId'
     };
 
     function displaySingle(data) {
@@ -246,15 +244,19 @@
                 .classed('clickable', true),
             table = detail.appendBody('table'),
             tbody = table.append('tbody'),
-            navFn;
+            navFn,
+            navPath;
 
         gs.addGlyph(svg, (data.type || 'unknown'), 26);
         title.text(data.title);
 
-        // only add navigation when displaying a device
-        if (isDevice[data.type]) {
+        // add navigation hot-link if defined
+        navPath = data.navPath;
+        if (navPath) {
             navFn = function () {
-                ns.navTo(devPath, { devId: data.id });
+                var arg = {};
+                arg[navPathIdKey[navPath]] = data.id;
+                ns.navTo(navPath, arg);
             };
 
             svg.on('click', navFn);
diff --git a/web/gui/src/main/webapp/app/view/topo/topoToolbar.js b/web/gui/src/main/webapp/app/view/topo/topoToolbar.js
index 351154c..82d14ac 100644
--- a/web/gui/src/main/webapp/app/view/topo/topoToolbar.js
+++ b/web/gui/src/main/webapp/app/view/topo/topoToolbar.js
@@ -75,6 +75,7 @@
             hosts: 0,
             offdev: 1,
             dlbls: 0,
+            hlbls: 0,
             porthl: 1,
             bg: 0,
             spr: 0,
@@ -278,7 +279,7 @@
         toolbar.toggle();
         persistTopoPrefs('toolbar');
     }
-    
+
     function selectOverlay(ovid) {
         var idx = ovIndex[defaultOverlay] || 0,
             pidx = (ovid === null) ? 0 : ovIndex[ovid] || -1;
diff --git a/web/gui/src/main/webapp/app/view/topo2/topo2Prefs.js b/web/gui/src/main/webapp/app/view/topo2/topo2Prefs.js
index ef3698e..9d822c8 100644
--- a/web/gui/src/main/webapp/app/view/topo2/topo2Prefs.js
+++ b/web/gui/src/main/webapp/app/view/topo2/topo2Prefs.js
@@ -26,6 +26,7 @@
         hosts: 0,
         offdev: 1,
         dlbls: 0,
+        hlbls: 0,
         porthl: 1,
         bg: 0,
         spr: 0,
diff --git a/web/gui/src/main/webapp/tests/app/view/topo/topoForce-spec.js b/web/gui/src/main/webapp/tests/app/view/topo/topoForce-spec.js
index f0a2914..d14e9ab 100644
--- a/web/gui/src/main/webapp/tests/app/view/topo/topoForce-spec.js
+++ b/web/gui/src/main/webapp/tests/app/view/topo/topoForce-spec.js
@@ -41,8 +41,8 @@
 
             'updateDeviceColors', 'toggleHosts',
             'togglePorts', 'toggleOffline',
-            'cycleDeviceLabels', 'unpin', 'showMastership', 'showBadLinks',
-            'setNodeScale',
+            'cycleDeviceLabels', 'cycleHostLabels', 'unpin',
+            'showMastership', 'showBadLinks', 'setNodeScale',
 
             'resetAllLocations', 'addDevice', 'updateDevice', 'removeDevice',
             'addHost', 'updateHost', 'moveHost', 'removeHost',
diff --git a/web/gui/src/main/webapp/tests/app/view/topo/topoModel-spec.js b/web/gui/src/main/webapp/tests/app/view/topo/topoModel-spec.js
index d1db42a..dd32491 100644
--- a/web/gui/src/main/webapp/tests/app/view/topo/topoModel-spec.js
+++ b/web/gui/src/main/webapp/tests/app/view/topo/topoModel-spec.js
@@ -210,10 +210,11 @@
     it('should define api functions', function () {
         expect(fs.areFunctions(tms, [
             'initModel', 'newDim', 'destroyModel',
-            'positionNode', 'resetAllLocations', 'createDeviceNode', 'createHostNode',
+            'positionNode', 'resetAllLocations',
+            'createDeviceNode', 'createHostNode',
             'createHostLink', 'createLink',
             'coordFromLngLat', 'lngLatFromCoord',
-            'findLink', 'findLinkById', 'findDevices',
+            'findLink', 'findLinkById', 'findDevices', 'findHosts',
             'findAttachedHosts', 'findAttachedLinks', 'findBadLinks'
         ])).toBeTruthy();
     });