Repository: eclipse/leshan
Pull request #1032 (2021-06-22 10:19:39)

leshan-bsserver-demo UIv2 : Add a way to watch Bootstap Session lifecycle.
This is now possible to watch a bootstrap session in live : 

 <img src="https://user-images.githubusercontent.com/840294/122907861-d6894a00-d353-11eb-987c-312df13b90ea.png"  width="700" > 




Patch:
diff --git a/leshan-bsserver-demo/pom.xml b/leshan-bsserver-demo/pom.xml
index 2a2be857..256d9246 100644
--- a/leshan-bsserver-demo/pom.xml
+++ b/leshan-bsserver-demo/pom.xml
@@ -43,10 +43,18 @@ Contributors:
             <groupId>org.eclipse.jetty</groupId>
             <artifactId>jetty-webapp</artifactId>
         </dependency>
+        <dependency>
+            <groupId>org.eclipse.jetty</groupId>
+            <artifactId>jetty-servlets</artifactId>
+        </dependency>
         <dependency>
             <groupId>com.google.code.gson</groupId>
             <artifactId>gson</artifactId>
         </dependency>
+        <dependency>
+            <groupId>com.fasterxml.jackson.core</groupId>
+            <artifactId>jackson-databind</artifactId>
+        </dependency>
 
         <!-- runtime dependencies -->
         <dependency>
diff --git a/leshan-bsserver-demo/src/main/java/org/eclipse/leshan/server/bootstrap/demo/LeshanBootstrapServerDemo.java b/leshan-bsserver-demo/src/main/java/org/eclipse/leshan/server/bootstrap/demo/LeshanBootstrapServerDemo.java
index 28613187..9124e5dd 100755
--- a/leshan-bsserver-demo/src/main/java/org/eclipse/leshan/server/bootstrap/demo/LeshanBootstrapServerDemo.java
+++ b/leshan-bsserver-demo/src/main/java/org/eclipse/leshan/server/bootstrap/demo/LeshanBootstrapServerDemo.java
@@ -48,6 +48,7 @@ import org.eclipse.leshan.core.model.ObjectLoader;
 import org.eclipse.leshan.core.model.ObjectModel;
 import org.eclipse.leshan.core.util.SecurityUtil;
 import org.eclipse.leshan.server.bootstrap.demo.servlet.BootstrapServlet;
+import org.eclipse.leshan.server.bootstrap.demo.servlet.EventServlet;
 import org.eclipse.leshan.server.bootstrap.demo.servlet.ServerServlet;
 import org.eclipse.leshan.server.californium.LeshanServerBuilder;
 import org.eclipse.leshan.server.californium.bootstrap.LeshanBootstrapServer;
@@ -476,6 +477,12 @@ public class LeshanBootstrapServerDemo {
         root.addServlet(serverServletHolder, "/api/server/*");
         root.addServlet(serverServletHolder, "/v2/api/server/*"); // Temporary code to be able to serve both UI
 
+        EventServlet eventServlet = new EventServlet(bsServer);
+        ServletHolder eventServletHolder = new ServletHolder(eventServlet);
+        root.addServlet(eventServletHolder, "/event/*"); // Temporary code to be able to serve both UI
+        root.addServlet(eventServletHolder, "/api/event/*");
+        root.addServlet(eventServletHolder, "/v2/api/event/*"); // Temporary code to be able to serve both UI
+
         server.setHandler(root);
         /* **************************************************************** */
 
diff --git a/leshan-bsserver-demo/src/main/java/org/eclipse/leshan/server/bootstrap/demo/servlet/EventServlet.java b/leshan-bsserver-demo/src/main/java/org/eclipse/leshan/server/bootstrap/demo/servlet/EventServlet.java
new file mode 100644
index 00000000..963db3af
--- /dev/null
+++ b/leshan-bsserver-demo/src/main/java/org/eclipse/leshan/server/bootstrap/demo/servlet/EventServlet.java
@@ -0,0 +1,358 @@
+/*******************************************************************************
+ * Copyright (c) 2013-2015 Sierra Wireless and others.
+ * 
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v2.0
+ * and Eclipse Distribution License v1.0 which accompany this distribution.
+ * 
+ * The Eclipse Public License is available at
+ *    http://www.eclipse.org/legal/epl-v20.html
+ * and the Eclipse Distribution License is available at
+ *    http://www.eclipse.org/org/documents/edl-v10.html.
+ * 
+ * Contributors:
+ *     Sierra Wireless - initial API and implementation
+ *******************************************************************************/
+package org.eclipse.leshan.server.bootstrap.demo.servlet;
+
+import java.io.IOException;
+import java.util.Collections;
+import java.util.Date;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+
+import javax.servlet.http.HttpServletRequest;
+
+import org.eclipse.jetty.servlets.EventSource;
+import org.eclipse.jetty.servlets.EventSourceServlet;
+import org.eclipse.leshan.core.request.BootstrapDeleteRequest;
+import org.eclipse.leshan.core.request.BootstrapDiscoverRequest;
+import org.eclipse.leshan.core.request.BootstrapDownlinkRequest;
+import org.eclipse.leshan.core.request.BootstrapRequest;
+import org.eclipse.leshan.core.request.BootstrapWriteRequest;
+import org.eclipse.leshan.core.request.Identity;
+import org.eclipse.leshan.core.response.BootstrapDiscoverResponse;
+import org.eclipse.leshan.core.response.LwM2mResponse;
+import org.eclipse.leshan.server.bootstrap.BootstrapFailureCause;
+import org.eclipse.leshan.server.bootstrap.BootstrapSession;
+import org.eclipse.leshan.server.bootstrap.BootstrapSessionListener;
+import org.eclipse.leshan.server.californium.bootstrap.LeshanBootstrapServer;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+public class EventServlet extends EventSourceServlet {
+    private static final long serialVersionUID = 1L;
+
+    private static final String EVENT_BOOTSTRAP_SESSION = "BSSESSION";
+    private static final String QUERY_PARAM_ENDPOINT = "ep";
+
+    private static final Logger LOG = LoggerFactory.getLogger(EventServlet.class);
+
+    private ObjectMapper objectMapper;
+    private Set<LeshanEventSource> eventSources = Collections
+            .newSetFromMap(new ConcurrentHashMap<LeshanEventSource, Boolean>());
+
+    @SuppressWarnings("unused")
+    private class BootstrapEvent {
+        public String name;
+        public String endpoint;
+        public Date time;
+        public String message;
+
+        public BootstrapEvent(String name, String endpoint, String message) {
+            this.name = name;
+            this.endpoint = endpoint;
+            this.time = new Date();
+            this.message = message;
+        }
+    }
+
+    private final BootstrapSessionListener sessionListener = new BootstrapSessionListener() {
+
+        @Override
+        public void sessionInitiated(BootstrapRequest request, Identity clientIdentity) {
+            try {
+                String endpointName = request.getEndpointName();
+                StringBuilder b = new StringBuilder();
+                b.append("Bootstrap Request from ");
+                b.append(clientIdentity.getPeerAddress());
+                if (request.getPreferredContentFormat() != null) {
+                    b.append("\n");
+                    b.append("Preferred Content Format:  ");
+                    b.append(request.getPreferredContentFormat().toString());
+                }
+                if (!request.getAdditionalAttributes().isEmpty()) {
+                    b.append("\n");
+                    b.append("Additional attributes: ");
+                    b.append(request.getAdditionalAttributes().toString());
+                }
+
+                sendEvent(EVENT_BOOTSTRAP_SESSION,
+                        objectMapper.writeValueAsString(new BootstrapEvent("new session", endpointName, b.toString())),
+                        endpointName);
+            } catch (JsonProcessingException e) {
+                LOG.error("Bootstap Event JSON serialization failed", e);
+            }
+        }
+
+        @Override
+        public void unAuthorized(BootstrapRequest request, Identity clientIdentity) {
+            try {
+                String endpointName = request.getEndpointName();
+                StringBuilder b = new StringBuilder();
+                b.append(clientIdentity);
+                b.append(" is not allowed to connect.");
+                b.append("\n");
+                b.append("(probably bad credentials)");
+
+                sendEvent(EVENT_BOOTSTRAP_SESSION,
+                        objectMapper.writeValueAsString(new BootstrapEvent("unauthorized", endpointName, b.toString())),
+                        endpointName);
+            } catch (JsonProcessingException e) {
+                LOG.error("Bootstap Event JSON serialization failed", e);
+            }
+        }
+
+        @Override
+        public void authorized(BootstrapSession session) {
+            try {
+                String endpointName = session.getEndpoint();
+                StringBuilder b = new StringBuilder();
+                b.append(session.getIdentity());
+                b.append(" is allowed to connect.");
+
+                sendEvent(EVENT_BOOTSTRAP_SESSION,
+                        objectMapper.writeValueAsString(new BootstrapEvent("authorized", endpointName, b.toString())),
+                        endpointName);
+            } catch (JsonProcessingException e) {
+                LOG.error("Bootstap Event JSON serialization failed", e);
+            }
+        }
+
+        @Override
+        public void noConfig(BootstrapSession session) {
+            try {
+                String endpointName = session.getEndpoint();
+                StringBuilder b = new StringBuilder();
+                b.append("No config to apply to this client.");
+
+                sendEvent(EVENT_BOOTSTRAP_SESSION,
+                        objectMapper.writeValueAsString(new BootstrapEvent("no config", endpointName, b.toString())),
+                        endpointName);
+            } catch (JsonProcessingException e) {
+                LOG.error("Bootstap Event JSON serialization failed", e);
+            }
+        }
+
+        @Override
+        public void sendRequest(BootstrapSession session, BootstrapDownlinkRequest<? extends LwM2mResponse> request) {
+            try {
+                if (request instanceof BootstrapDiscoverRequest) {
+                    String endpointName = session.getEndpoint();
+                    StringBuilder b = new StringBuilder();
+                    b.append("Send DISCOVER request on ");
+                    b.append(request.getPath().toString());
+                    sendEvent(EVENT_BOOTSTRAP_SESSION, objectMapper.writeValueAsString(
+                            new BootstrapEvent("send discover", endpointName, b.toString())), endpointName);
+                } else if (request instanceof BootstrapDeleteRequest) {
+                    String endpointName = session.getEndpoint();
+                    StringBuilder b = new StringBuilder();
+                    b.append("Send DELETE request on ");
+                    b.append(request.getPath().toString());
+                    sendEvent(EVENT_BOOTSTRAP_SESSION, objectMapper.writeValueAsString(
+                            new BootstrapEvent("send delete", endpointName, b.toString())), endpointName);
+                } else if (request instanceof BootstrapWriteRequest) {
+                    String endpointName = session.getEndpoint();
+                    StringBuilder b = new StringBuilder();
+                    b.append("Send WRITE request on ");
+                    b.append(request.getPath().toString());
+                    b.append(" using ");
+                    b.append(((BootstrapWriteRequest) request).getContentFormat());
+                    b.append('\n');
+                    ((BootstrapWriteRequest) request).getNode().appendPrettyNode(b, request.getPath());
+                    sendEvent(EVENT_BOOTSTRAP_SESSION, objectMapper.writeValueAsString(
+                            new BootstrapEvent("send write", endpointName, b.toString())), endpointName);
+                } else {
+                    String endpointName = session.getEndpoint();
+                    StringBuilder b = new StringBuilder();
+                    b.append("Send request: ");
+                    b.append(request.toString());
+                    sendEvent(EVENT_BOOTSTRAP_SESSION, objectMapper.writeValueAsString(
+                            new BootstrapEvent("send request", endpointName, b.toString())), endpointName);
+                }
+
+            } catch (JsonProcessingException e) {
+                LOG.error("Bootstap Event JSON serialization failed", e);
+            }
+        }
+
+        @Override
+        public void onResponseSuccess(BootstrapSession session,
+                BootstrapDownlinkRequest<? extends LwM2mResponse> request, LwM2mResponse response) {
+            try {
+                if (request instanceof BootstrapDiscoverResponse) {
+                    String endpointName = session.getEndpoint();
+                    StringBuilder b = new StringBuilder();
+                    b.append("Receive DISCOVER reponse\n");
+                    b.append(response.getCode().toString());
+                    b.append(((BootstrapDiscoverResponse) request).getObjectLinks().toString());
+                    sendEvent(EVENT_BOOTSTRAP_SESSION,
+                            objectMapper.writeValueAsString(
+                                    new BootstrapEvent("receive success response", endpointName, b.toString())),
+                            endpointName);
+                } else {
+                    String endpointName = session.getEndpoint();
+                    StringBuilder b = new StringBuilder();
+                    b.append("Receive ");
+                    b.append(response.getCode().toString());
+                    sendEvent(EVENT_BOOTSTRAP_SESSION,
+                            objectMapper.writeValueAsString(
+                                    new BootstrapEvent("receive success response", endpointName, b.toString())),
+                            endpointName);
+                }
+            } catch (JsonProcessingException e) {
+                LOG.error("Bootstap Event JSON serialization failed", e);
+            }
+        }
+
+        @Override
+        public void onResponseError(BootstrapSession session, BootstrapDownlinkRequest<? extends LwM2mResponse> request,
+                LwM2mResponse response) {
+            try {
+                String endpointName = session.getEndpoint();
+                StringBuilder b = new StringBuilder();
+                b.append("Receive ");
+                b.append(response.getCode().toString());
+                if (response.getErrorMessage() != null) {
+                    b.append("\n");
+                    b.append(response.getErrorMessage());
+                }
+                sendEvent(EVENT_BOOTSTRAP_SESSION,
+                        objectMapper.writeValueAsString(
+                                new BootstrapEvent("receive error response", endpointName, b.toString())),
+                        endpointName);
+            } catch (JsonProcessingException e) {
+                LOG.error("Bootstap Event JSON serialization failed", e);
+            }
+        }
+
+        @Override
+        public void onRequestFailure(BootstrapSession session,
+                BootstrapDownlinkRequest<? extends LwM2mResponse> request, Throwable cause) {
+            try {
+                String endpointName = session.getEndpoint();
+                StringBuilder b = new StringBuilder();
+                b.append("Request was not send because of failure : \n");
+                appendError(cause, b);
+                sendEvent(EVENT_BOOTSTRAP_SESSION, objectMapper.writeValueAsString(
+                        new BootstrapEvent("request failure", endpointName, b.toString())), endpointName);
+            } catch (JsonProcessingException e) {
+                LOG.error("Bootstap Event JSON serialization failed", e);
+            }
+        }
+
+        public void appendError(Throwable e, StringBuilder b) {
+            if (e.getMessage() != null) {
+                b.append(e.getMessage());
+            } else {
+                b.append(e.getClass().getSimpleName());
+            }
+            if (e.getCause() != null) {
+                b.append("\ncaused by : ");
+                this.appendError(e.getCause(), b);
+            }
+        }
+
+        @Override
+        public void end(BootstrapSession session) {
+            try {
+                String endpointName = session.getEndpoint();
+                StringBuilder b = new StringBuilder();
+                b.append("Bootstrap session finished with success");
+
+                sendEvent(EVENT_BOOTSTRAP_SESSION,
+                        objectMapper.writeValueAsString(new BootstrapEvent("finished", endpointName, b.toString())),
+                        endpointName);
+            } catch (JsonProcessingException e) {
+                LOG.error("Bootstap Event JSON serialization failed", e);
+            }
+        }
+
+        @Override
+        public void failed(BootstrapSession session, BootstrapFailureCause cause) {
+            try {
+                String endpointName = session.getEndpoint();
+                StringBuilder b = new StringBuilder();
+                b.append("Bootstrap session failed : ");
+                b.append(cause.toString());
+
+                sendEvent(EVENT_BOOTSTRAP_SESSION,
+                        objectMapper.writeValueAsString(new BootstrapEvent("failed", endpointName, b.toString())),
+                        endpointName);
+            } catch (JsonProcessingException e) {
+                LOG.error("Bootstap Event JSON serialization failed", e);
+            }
+        }
+
+    };
+
+    public EventServlet(LeshanBootstrapServer server) {
+        server.addListener(sessionListener);
+        objectMapper = new ObjectMapper();
+    }
+
+    private synchronized void sendEvent(String event, String data, String endpoint) {
+        if (LOG.isDebugEnabled()) {
+            LOG.debug("Dispatching {} event from endpoint {}", event, endpoint);
+        }
+
+        for (LeshanEventSource eventSource : eventSources) {
+            if (eventSource.getEndpoint() == null || eventSource.getEndpoint().equals(endpoint)) {
+                eventSource.sentEvent(event, data);
+            }
+        }
+    }
+
+    @Override
+    protected EventSource newEventSource(HttpServletRequest req) {
+        String endpoint = req.getParameter(QUERY_PARAM_ENDPOINT);
+        return new LeshanEventSource(endpoint);
+    }
+
+    private class LeshanEventSource implements EventSource {
+
+        private String endpoint;
+        private Emitter emitter;
+
+        public LeshanEventSource(String endpoint) {
+            this.endpoint = endpoint;
+        }
+
+        @Override
+        public void onOpen(Emitter emitter) throws IOException {
+            this.emitter = emitter;
+            eventSources.add(this);
+        }
+
+        @Override
+        public void onClose() {
+            eventSources.remove(this);
+        }
+
+        public void sentEvent(String event, String data) {
+            try {
+                emitter.event(event, data);
+            } catch (IOException e) {
+                e.printStackTrace();
+                onClose();
+            }
+        }
+
+        public String getEndpoint() {
+            return endpoint;
+        }
+    }
+}
diff --git a/leshan-bsserver-demo/webapp2/package.json b/leshan-bsserver-demo/webapp2/package.json
index 183f8512..8d09208b 100644
--- a/leshan-bsserver-demo/webapp2/package.json
+++ b/leshan-bsserver-demo/webapp2/package.json
@@ -13,7 +13,9 @@
     "axios": "^0.21.1",
     "core-js": "^3.6.5",
     "vue": "^2.6.11",
+    "vue-moment": "^4.1.0",
     "vue-router": "^3.2.0",
+    "vue-sse": "^2.0.2",
     "vuetify": "^2.4.5",
     "vuetify-dialog": "^2.0.14"
   },
diff --git a/leshan-bsserver-demo/webapp2/src/main.js b/leshan-bsserver-demo/webapp2/src/main.js
index 3e90a38c..f77a4dd1 100644
--- a/leshan-bsserver-demo/webapp2/src/main.js
+++ b/leshan-bsserver-demo/webapp2/src/main.js
@@ -1,6 +1,8 @@
 import Vue from 'vue'
 import './plugins/axios'
 import './plugins/dialog'
+import './plugins/moment'
+import './plugins/sse'
 import App from './App.vue'
 import vuetify from './plugins/vuetify';
 import router from './router'
diff --git a/leshan-bsserver-demo/webapp2/src/plugins/moment.js b/leshan-bsserver-demo/webapp2/src/plugins/moment.js
new file mode 100644
index 00000000..69bb0e2f
--- /dev/null
+++ b/leshan-bsserver-demo/webapp2/src/plugins/moment.js
@@ -0,0 +1,4 @@
+import VueMoment from "vue-moment" 
+import Vue from "vue"
+
+Vue.use(VueMoment);
\ No newline at end of file
diff --git a/leshan-bsserver-demo/webapp2/src/plugins/sse.js b/leshan-bsserver-demo/webapp2/src/plugins/sse.js
new file mode 100644
index 00000000..fbc50153
--- /dev/null
+++ b/leshan-bsserver-demo/webapp2/src/plugins/sse.js
@@ -0,0 +1,7 @@
+import VueSSE from 'vue-sse';
+import Vue from "vue";
+
+
+Vue.use(VueSSE, {
+    format: 'json',
+  });
diff --git a/leshan-bsserver-demo/webapp2/src/router/index.js b/leshan-bsserver-demo/webapp2/src/router/index.js
index 16a11231..ef079ff7 100644
--- a/leshan-bsserver-demo/webapp2/src/router/index.js
+++ b/leshan-bsserver-demo/webapp2/src/router/index.js
@@ -2,6 +2,7 @@ import Vue from "vue";
 import VueRouter from "vue-router";
 import Bootstrap from "../views/Bootstrap.vue";
 import About from "../views/About.vue";
+import Client from "../views/Client.vue"
 
 Vue.use(VueRouter);
 
@@ -15,16 +16,10 @@ const routes = [
     name: "Bootstrap",
     component: Bootstrap,
   },
-  /*{
+  {
     path: "/bootstrap/:endpoint",
     component: Client,
-    children: [
-      {
-        path: ":objectid",
-        component: ObjectView,
-      },
-    ],
-  },*/
+  },
   {
     path: "/about",
     name: "About",
diff --git a/leshan-bsserver-demo/webapp2/src/views/Bootstrap.vue b/leshan-bsserver-demo/webapp2/src/views/Bootstrap.vue
index 42dc83a8..0718dc5e 100644
--- a/leshan-bsserver-demo/webapp2/src/views/Bootstrap.vue
+++ b/leshan-bsserver-demo/webapp2/src/views/Bootstrap.vue
@@ -5,6 +5,7 @@
     item-key="endpoint"
     sort-by="endpoint"
     class="elevation-0"
+    @click:row="openLink"
     dense
   >
     <template v-slot:top>
@@ -54,7 +55,7 @@
     </template>
     <!--custom display for "actions" column-->
     <template v-slot:item.actions="{ item }">
-      <v-icon small @click="deleteConfig(item)">
+      <v-icon small @click.stop="deleteConfig(item)">
         mdi-delete
       </v-icon>
     </template>
@@ -88,6 +89,9 @@ export default {
   },
 
   methods: {
+    openLink(bs) {
+      this.$router.push(`/bootstrap/${bs.endpoint}`);
+    },
     fromAscii(ascii) {
       var bytearray = [];
       for (var i in ascii) {
diff --git a/leshan-bsserver-demo/webapp2/src/views/Client.vue b/leshan-bsserver-demo/webapp2/src/views/Client.vue
new file mode 100644
index 00000000..3303348c
--- /dev/null
+++ b/leshan-bsserver-demo/webapp2/src/views/Client.vue
@@ -0,0 +1,118 @@
+<template>
+  <v-card elevation="0" >
+    <v-card-text>
+      <v-card-title class="justify-center"> Client "{{ $route.params.endpoint }}" </v-card-title>
+
+      <v-card-subtitle class="text-center" >
+        Watch your client bootstrap session.
+
+        <v-btn icon title="Clear timeline" @click="events = []">
+          <v-icon>mdi-delete-sweep-outline</v-icon>
+        </v-btn>
+      </v-card-subtitle>
+
+      <v-timeline align-top>
+        <v-timeline-item
+          v-for="event in events"
+          :key="event.id"
+          :color="event.color"
+          small
+          :large="event.large"
+          right
+          :icon="event.icon"
+        >
+          <template v-slot:opposite>
+            <span>{{
+              new Date(event.time) | moment("MMM D, h:mm:ss.SSS A")
+            }}</span>
+          </template>
+          <div>
+            <div class="font-weight-normal">
+              <strong>{{ event.name }}</strong>
+            </div>
+            <div style="white-space: pre">{{ event.message }}</div>
+          </div>
+        </v-timeline-item>
+      </v-timeline>
+      <div ref="end"></div>
+    </v-card-text>
+  </v-card>
+</template>
+<script>
+export default {
+  data: () => ({
+    events: [
+      // name : string
+      // time : timestamp
+      // message : string
+      // color : color
+      // left: boolean
+      // icon: string (mid-*)
+    ],
+    nonce: 0,
+  }),
+
+  mounted() {
+    this.sse = this.$sse
+      .create({
+        url: "api/event",
+      })
+      .on("BSSESSION", (event) => {
+        event.id = this.nonce++;
+        event.large = this.islarge(event);
+        event.icon = this.getIcon(event);
+        event.color = this.getColor(event);
+        this.events.push(event);
+        this.$nextTick(function() {
+          this.$vuetify.goTo(this.$refs.end);
+        });
+      })
+      .on("error", (err) => {
+        console.error("sse unexpected error", err);
+      });
+
+    this.sse.connect().catch((err) => {
+      console.error("Failed to connect to server", err);
+    });
+  },
+
+  methods: {
+    islarge(event) {
+      let large = ["new session", "finished", "failed", "unauthorized"];
+      return !!large.find((e) => event.name.startsWith(e));
+    },
+
+    getIcon(event) {
+      let icons = {
+        "new session": "mdi-play",
+        unauthorized: "mdi-account-cancel-outline",
+        "no config": "mdi-database-remove",
+        authorized: "mdi-account-check-outline",
+        "send write": "mdi-lead-pencil",
+        "send delete": "mdi-delete-outline",
+        "send discover": "mdi-magnify",
+        "receive success response": "mdi-check",
+        "receive error response": "mdi-alert-outline",
+        finished: "mdi-check-bold",
+        "request failure": "mdi-close-circle-outline",
+        failed: "mdi-exclamation-thick",
+      };
+      return icons[event.name];
+    },
+
+    getColor(event) {
+      let colors = {
+        "no config": "red",
+        unauthorized: "red",
+        authorized: "green",
+        "receive success response": "green",
+        "receive error response": "orange",
+        "request failure": "red",
+        finished: "green",
+        failed: "red",
+      };
+      return colors[event.name];
+    },
+  },
+};
+</script>
diff --git a/leshan-bsserver-demo/webapp2/yarn.lock b/leshan-bsserver-demo/webapp2/yarn.lock
index 0307ffd6..ff5e97ee 100644
--- a/leshan-bsserver-demo/webapp2/yarn.lock
+++ b/leshan-bsserver-demo/webapp2/yarn.lock
@@ -3562,6 +3562,11 @@ event-pubsub@4.3.0:
   resolved "https://registry.yarnpkg.com/event-pubsub/-/event-pubsub-4.3.0.tgz#f68d816bc29f1ec02c539dc58c8dd40ce72cb36e"
   integrity sha512-z7IyloorXvKbFx9Bpie2+vMJKKx1fH1EN5yiTfp8CiLOTptSYy1g8H4yDpGlEdshL1PBiFtBHepF2cNsqeEeFQ==
 
+event-source-polyfill@^1.0.22:
+  version "1.0.24"
+  resolved "https://registry.yarnpkg.com/event-source-polyfill/-/event-source-polyfill-1.0.24.tgz#46045e9af2190fd51234de0075d71c909907f937"
+  integrity sha512-aEtMhrH5ww3X6RgbsNcwu0whw8zjOoeRnwPqRKqKuxWS5KlAZhCY+rTm6wMlHOXbxmLGn8lW6Xox7rfpBExzGA==
+
 eventemitter3@^4.0.0:
   version "4.0.7"
   resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f"
@@ -5533,6 +5538,11 @@ mkdirp@^0.5.1, mkdirp@^0.5.3, mkdirp@^0.5.5, mkdirp@~0.5.1:
   dependencies:
     minimist "^1.2.5"
 
+moment@^2.19.2:
+  version "2.29.1"
+  resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.1.tgz#b2be769fa31940be9eeea6469c075e35006fa3d3"
+  integrity sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ==
+
 move-concurrently@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/move-concurrently/-/move-concurrently-1.0.1.tgz#be2c005fda32e0b29af1f05d7c4b33214c701f92"
@@ -8274,11 +8284,25 @@ vue-loader@^15.9.2:
     vue-hot-reload-api "^2.3.0"
     vue-style-loader "^4.1.0"
 
+vue-moment@^4.1.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/vue-moment/-/vue-moment-4.1.0.tgz#092a8ff723a96c6f85a0a8e23ad30f0bf320f3b0"
+  integrity sha512-Gzisqpg82ItlrUyiD9d0Kfru+JorW2o4mQOH06lEDZNgxci0tv/fua1Hl0bo4DozDV2JK1r52Atn/8QVCu8qQw==
+  dependencies:
+    moment "^2.19.2"
+
 vue-router@^3.2.0:
   version "3.5.1"
   resolved "https://registry.yarnpkg.com/vue-router/-/vue-router-3.5.1.tgz#edf3cf4907952d1e0583e079237220c5ff6eb6c9"
   integrity sha512-RRQNLT8Mzr8z7eL4p7BtKvRaTSGdCbTy2+Mm5HTJvLGYSSeG9gDzNasJPP/yOYKLy+/cLG/ftrqq5fvkFwBJEw==
 
+vue-sse@^2.0.2:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/vue-sse/-/vue-sse-2.2.0.tgz#f1f3bafb77235b9f5f3ee9129c2a28c43c422b28"
+  integrity sha512-Roly9mQBeRnuY4O3UTFZcclHMTRXpK2XrrXkZQ/RQZZmY4U1pPxnx4QMwT4WDXCgIgTZpZW4wNhQdZbgI/hx8Q==
+  dependencies:
+    event-source-polyfill "^1.0.22"
+
 vue-style-loader@^4.1.0, vue-style-loader@^4.1.2:
   version "4.1.3"
   resolved "https://registry.yarnpkg.com/vue-style-loader/-/vue-style-loader-4.1.3.tgz#6d55863a51fa757ab24e89d9371465072aa7bc35"
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/core/ResponseCode.java b/leshan-core/src/main/java/org/eclipse/leshan/core/ResponseCode.java
index 04f5d2d0..e88b904a 100644
--- a/leshan-core/src/main/java/org/eclipse/leshan/core/ResponseCode.java
+++ b/leshan-core/src/main/java/org/eclipse/leshan/core/ResponseCode.java
@@ -141,10 +141,7 @@ public class ResponseCode {
 
     @Override
     public String toString() {
-        if (UNKNOWN.equals(name))
-            return String.format("%s(%d)", name, code);
-        else
-            return name;
+        return String.format("%s(%d)", name, code);
     }
 
     @Override
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/core/node/LwM2mMultipleResource.java b/leshan-core/src/main/java/org/eclipse/leshan/core/node/LwM2mMultipleResource.java
index 1dbdfcfe..5d624886 100644
--- a/leshan-core/src/main/java/org/eclipse/leshan/core/node/LwM2mMultipleResource.java
+++ b/leshan-core/src/main/java/org/eclipse/leshan/core/node/LwM2mMultipleResource.java
@@ -23,6 +23,7 @@ import java.util.HashMap;
 import java.util.Map;
 import java.util.Map.Entry;
 import java.util.NoSuchElementException;
+import java.util.TreeMap;
 
 import org.eclipse.leshan.core.model.ResourceModel.Type;
 import org.eclipse.leshan.core.util.Validate;
@@ -252,4 +253,31 @@ public class LwM2mMultipleResource implements LwM2mResource {
         return String.format("LwM2mMultipleResource [id=%s, values=%s, type=%s]", id, instances, type);
     }
 
+    @Override
+    public String toPrettyString(LwM2mPath path) {
+        return appendPrettyNode(new StringBuilder(), path).toString();
+    }
+
+    @Override
+    public StringBuilder appendPrettyNode(StringBuilder b, LwM2mPath path) {
+        if (!path.isResource())
+            throw new IllegalArgumentException("path must be a resource path");
+        if (path.getResourceId() != id)
+            throw new IllegalArgumentException("path resource id must match this LwM2mMultipleResource id");
+
+        if (instances.isEmpty()) {
+            b.append(path).append(" : {}");
+        } else {
+            boolean first = true;
+            for (Entry<Integer, LwM2mResourceInstance> entry : new TreeMap<>(instances).entrySet()) {
+                if (first) {
+                    first = false;
+                } else {
+                    b.append("\n");
+                }
+                entry.getValue().appendPrettyNode(b, path.append(entry.getKey()));
+            }
+        }
+        return b;
+    }
 }
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/core/node/LwM2mNode.java b/leshan-core/src/main/java/org/eclipse/leshan/core/node/LwM2mNode.java
index 8a697fb6..92e303ed 100644
--- a/leshan-core/src/main/java/org/eclipse/leshan/core/node/LwM2mNode.java
+++ b/leshan-core/src/main/java/org/eclipse/leshan/core/node/LwM2mNode.java
@@ -30,4 +30,18 @@ public interface LwM2mNode {
      */
     void accept(LwM2mNodeVisitor visitor);
 
+    /**
+     * Convert node to pretty string
+     * 
+     * @param path of this node;
+     */
+    String toPrettyString(LwM2mPath path);
+
+    /**
+     * Append pretty node to given {@link StringBuilder}
+     * 
+     * @param b string builder to which node should be appended
+     * @param path of this node;
+     */
+    StringBuilder appendPrettyNode(StringBuilder b, LwM2mPath path);
 }
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/core/node/LwM2mNodeUtil.java b/leshan-core/src/main/java/org/eclipse/leshan/core/node/LwM2mNodeUtil.java
index 27431ada..cd61af1d 100644
--- a/leshan-core/src/main/java/org/eclipse/leshan/core/node/LwM2mNodeUtil.java
+++ b/leshan-core/src/main/java/org/eclipse/leshan/core/node/LwM2mNodeUtil.java
@@ -18,6 +18,8 @@ package org.eclipse.leshan.core.node;
 import java.util.Collection;
 import java.util.Iterator;
 
+import org.eclipse.leshan.core.model.ResourceModel.Type;
+
 /**
  * Utility class about {@link LwM2mNode} and {@link LwM2mPath}.
  */
@@ -140,4 +142,20 @@ public class LwM2mNodeUtil {
             throw new LwM2mNodeException("Instance id should be undefined");
         }
     }
+
+    public static void valueToPrettyString(StringBuilder b, Object value, Type type) {
+        switch (type) {
+        case STRING:
+            b.append("\"").append(value).append("\"");
+            break;
+        case OPAQUE:
+            // We don't print OPAQUE value as this could be credentials one.
+            // Not ideal but didn't find better way for now.
+            b.append(((byte[]) value).length).append(" Bytes");
+            break;
+        default:
+            b.append(value);
+            break;
+        }
+    }
 }
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/core/node/LwM2mObject.java b/leshan-core/src/main/java/org/eclipse/leshan/core/node/LwM2mObject.java
index 331c11f4..f258ca6f 100644
--- a/leshan-core/src/main/java/org/eclipse/leshan/core/node/LwM2mObject.java
+++ b/leshan-core/src/main/java/org/eclipse/leshan/core/node/LwM2mObject.java
@@ -20,6 +20,8 @@ import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.Map;
+import java.util.Map.Entry;
+import java.util.TreeMap;
 
 /**
  * The top level element in the LWM2M resource tree.
@@ -88,6 +90,34 @@ public class LwM2mObject implements LwM2mNode {
         return String.format("LwM2mObject [id=%s, instances=%s]", id, instances);
     }
 
+    @Override
+    public String toPrettyString(LwM2mPath path) {
+        return appendPrettyNode(new StringBuilder(), path).toString();
+    }
+
+    @Override
+    public StringBuilder appendPrettyNode(StringBuilder b, LwM2mPath path) {
+        if (!path.isObject())
+            throw new IllegalArgumentException("path must be an object path");
+        if (path.getObjectId() != id)
+            throw new IllegalArgumentException("path object id must match this LwM2mObject id");
+
+        if (instances.isEmpty()) {
+            b.append(path).append(" : {}");
+        } else {
+            boolean first = true;
+            for (Entry<Integer, LwM2mObjectInstance> entry : new TreeMap<>(instances).entrySet()) {
+                if (first) {
+                    first = false;
+                } else {
+                    b.append("\n");
+                }
+                entry.getValue().appendPrettyNode(b, path.append(entry.getKey()));
+            }
+        }
+        return b;
+    }
+
     @Override
     public int hashCode() {
         final int prime = 31;
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/core/node/LwM2mObjectInstance.java b/leshan-core/src/main/java/org/eclipse/leshan/core/node/LwM2mObjectInstance.java
index dc7d283e..c85bc301 100644
--- a/leshan-core/src/main/java/org/eclipse/leshan/core/node/LwM2mObjectInstance.java
+++ b/leshan-core/src/main/java/org/eclipse/leshan/core/node/LwM2mObjectInstance.java
@@ -20,6 +20,8 @@ import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.Map;
+import java.util.Map.Entry;
+import java.util.TreeMap;
 
 import org.eclipse.leshan.core.node.codec.tlv.LwM2mNodeTlvDecoder;
 import org.eclipse.leshan.core.util.Validate;
@@ -151,4 +153,31 @@ public class LwM2mObjectInstance implements LwM2mNode {
         return true;
     }
 
+    @Override
+    public String toPrettyString(LwM2mPath path) {
+        return appendPrettyNode(new StringBuilder(), path).toString();
+    }
+
+    @Override
+    public StringBuilder appendPrettyNode(StringBuilder b, LwM2mPath path) {
+        if (!path.isObjectInstance())
+            throw new IllegalArgumentException("path must be an object instance path");
+        if (path.getObjectInstanceId() != id)
+            throw new IllegalArgumentException("path object instance id must match this LwM2mInstanceObject id");
+
+        if (resources.isEmpty()) {
+            b.append(path).append(" : {}");
+        } else {
+            boolean first = true;
+            for (Entry<Integer, LwM2mResource> entry : new TreeMap<>(resources).entrySet()) {
+                if (first) {
+                    first = false;
+                } else {
+                    b.append("\n");
+                }
+                entry.getValue().appendPrettyNode(b, path.append(entry.getKey()));
+            }
+        }
+        return b;
+    }
 }
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/core/node/LwM2mResourceInstance.java b/leshan-core/src/main/java/org/eclipse/leshan/core/node/LwM2mResourceInstance.java
index 9cead1d2..dc0ea23e 100644
--- a/leshan-core/src/main/java/org/eclipse/leshan/core/node/LwM2mResourceInstance.java
+++ b/leshan-core/src/main/java/org/eclipse/leshan/core/node/LwM2mResourceInstance.java
@@ -210,4 +210,21 @@ public class LwM2mResourceInstance implements LwM2mNode {
         return String.format("LwM2mResourceInstance [id=%s, value=%s, type=%s]", id,
                 type == Type.OPAQUE ? ((byte[]) value).length + "Bytes" : value, type);
     }
+
+    @Override
+    public String toPrettyString(LwM2mPath path) {
+        return appendPrettyNode(new StringBuilder(), path).toString();
+    }
+
+    @Override
+    public StringBuilder appendPrettyNode(StringBuilder b, LwM2mPath path) {
+        if (!path.isResourceInstance())
+            throw new IllegalArgumentException("path must be a resource instance path");
+        if (path.getResourceInstanceId() != id)
+            throw new IllegalArgumentException("path resource instance id must match this LwM2mResourceInstance id");
+
+        b.append(path).append(" : ");
+        LwM2mNodeUtil.valueToPrettyString(b, value, type);
+        return b;
+    }
 }
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/core/node/LwM2mSingleResource.java b/leshan-core/src/main/java/org/eclipse/leshan/core/node/LwM2mSingleResource.java
index e78fe81c..df96fe8a 100644
--- a/leshan-core/src/main/java/org/eclipse/leshan/core/node/LwM2mSingleResource.java
+++ b/leshan-core/src/main/java/org/eclipse/leshan/core/node/LwM2mSingleResource.java
@@ -263,4 +263,21 @@ public class LwM2mSingleResource implements LwM2mResource {
                 type == Type.OPAQUE ? ((byte[]) value).length + "Bytes" : value, type);
     }
 
+    @Override
+    public String toPrettyString(LwM2mPath path) {
+        return appendPrettyNode(new StringBuilder(), path).toString();
+    }
+
+    @Override
+    public StringBuilder appendPrettyNode(StringBuilder b, LwM2mPath path) {
+        if (!path.isResource())
+            throw new IllegalArgumentException("path must be a resource path");
+        if (path.getResourceId() != id)
+            throw new IllegalArgumentException("path resource id must match this LwM2mSingleResource id");
+
+        b.append(path).append(" : ");
+        LwM2mNodeUtil.valueToPrettyString(b, value, type);
+
+        return b;
+    }
 }
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/core/request/ContentFormat.java b/leshan-core/src/main/java/org/eclipse/leshan/core/request/ContentFormat.java
index 60ed54a8..b150201b 100644
--- a/leshan-core/src/main/java/org/eclipse/leshan/core/request/ContentFormat.java
+++ b/leshan-core/src/main/java/org/eclipse/leshan/core/request/ContentFormat.java
@@ -161,7 +161,7 @@ public class ContentFormat implements Comparable<ContentFormat> {
 
     @Override
     public String toString() {
-        return String.format("ContentFormat [name=%s, code=%s]", name, code);
+        return String.format("%s(%s)", name, code);
     }
 
     @Override


Test patch:
diff --git a/leshan-core-cf/src/test/java/org/eclipse/leshan/core/californium/ResponseCodeUtilTest.java b/leshan-core-cf/src/test/java/org/eclipse/leshan/core/californium/ResponseCodeUtilTest.java
index 2525ea48..7d5ca385 100644
--- a/leshan-core-cf/src/test/java/org/eclipse/leshan/core/californium/ResponseCodeUtilTest.java
+++ b/leshan-core-cf/src/test/java/org/eclipse/leshan/core/californium/ResponseCodeUtilTest.java
@@ -23,10 +23,11 @@ public class ResponseCodeUtilTest {
 
     @Test
     public void known_coap_code_to_known_lwm2m_Code() {
-        org.eclipse.leshan.core.ResponseCode lwM2mResponseCode = ResponseCodeUtil.toLwM2mResponseCode(ResponseCode.CREATED);
+        org.eclipse.leshan.core.ResponseCode lwM2mResponseCode = ResponseCodeUtil
+                .toLwM2mResponseCode(ResponseCode.CREATED);
 
         Assert.assertEquals(org.eclipse.leshan.core.ResponseCode.CREATED, lwM2mResponseCode);
-        Assert.assertEquals("CREATED", lwM2mResponseCode.toString());
+        Assert.assertEquals("CREATED(201)", lwM2mResponseCode.toString());
     }
 
     @Test
@@ -49,7 +50,8 @@ public class ResponseCodeUtilTest {
 
     @Test
     public void unknown_lwm2m_code_to_known_coap_code() {
-        ResponseCode coapResponseCode = ResponseCodeUtil.toCoapResponseCode(new org.eclipse.leshan.core.ResponseCode(503));
+        ResponseCode coapResponseCode = ResponseCodeUtil
+                .toCoapResponseCode(new org.eclipse.leshan.core.ResponseCode(503));
 
         Assert.assertEquals(ResponseCode.SERVICE_UNAVAILABLE, coapResponseCode);
     }
@@ -69,7 +71,8 @@ public class ResponseCodeUtilTest {
         // californium behavior is not really consistent
 
         // for success : code value is lost but we know we use an unknown code
-        ResponseCode coapResponseCode = ResponseCodeUtil.toCoapResponseCode(new org.eclipse.leshan.core.ResponseCode(206));
+        ResponseCode coapResponseCode = ResponseCodeUtil
+                .toCoapResponseCode(new org.eclipse.leshan.core.ResponseCode(206));
         Assert.assertEquals(ResponseCode._UNKNOWN_SUCCESS_CODE, coapResponseCode);
 
         // for client error,: unknown code is replace by BAD REQUEST ...