App metrics visualization manage 62/2362/21
authorAnandarup Sarkar <asarkar@research.att.com>
Tue, 28 Jan 2020 20:40:08 +0000 (15:40 -0500)
committerAnandarup Sarkar <asarkar@research.att.com>
Fri, 10 Apr 2020 20:14:45 +0000 (16:14 -0400)
Signed-off-by: Anandarup Sarkar <asarkar@research.att.com>
Signed-off-by: Lott, Christopher (cl778h) <cl778h@att.com>
Change-Id: I213c2161c5b8ef26376d00e67f01b9274bc216df

22 files changed:
.gitignore
dashboard/webapp-backend/src/main/java/org/oransc/ric/portal/dashboard/AppStatsManager.java [new file with mode: 0644]
dashboard/webapp-backend/src/main/java/org/oransc/ric/portal/dashboard/DashboardConstants.java
dashboard/webapp-backend/src/main/java/org/oransc/ric/portal/dashboard/config/AdminConfiguration.java
dashboard/webapp-backend/src/main/java/org/oransc/ric/portal/dashboard/controller/AdminController.java
dashboard/webapp-backend/src/main/java/org/oransc/ric/portal/dashboard/controller/CustomResponseEntityExceptionHandler.java
dashboard/webapp-backend/src/main/java/org/oransc/ric/portal/dashboard/exception/StatsManagerException.java [new file with mode: 0644]
dashboard/webapp-backend/src/main/java/org/oransc/ric/portal/dashboard/model/AppStats.java [new file with mode: 0644]
dashboard/webapp-backend/src/main/java/org/oransc/ric/portal/dashboard/model/StatsDetailsTransport.java [new file with mode: 0644]
dashboard/webapp-backend/src/test/java/org/oransc/ric/portal/dashboard/config/AdminMockConfiguration.java
dashboard/webapp-backend/src/test/java/org/oransc/ric/portal/dashboard/controller/AdminControllerTest.java
dashboard/webapp-frontend/src/app/interfaces/e2-mgr.types.ts
dashboard/webapp-frontend/src/app/rd.module.ts
dashboard/webapp-frontend/src/app/services/stats/stats.service.ts
dashboard/webapp-frontend/src/app/stats/stats-datasource.ts [new file with mode: 0644]
dashboard/webapp-frontend/src/app/stats/stats-dialog.component.html [new file with mode: 0644]
dashboard/webapp-frontend/src/app/stats/stats-dialog.component.scss [new file with mode: 0644]
dashboard/webapp-frontend/src/app/stats/stats-dialog.component.ts [new file with mode: 0644]
dashboard/webapp-frontend/src/app/stats/stats.component.html
dashboard/webapp-frontend/src/app/stats/stats.component.scss
dashboard/webapp-frontend/src/app/stats/stats.component.ts
docs/release-notes.rst

index 675d063..5cd11dc 100644 (file)
@@ -51,3 +51,6 @@
 # documentation
 .tox
 docs/_build/*
+
+# JSON internal data
+dashboard/webapp-backend/app-stats.json
diff --git a/dashboard/webapp-backend/src/main/java/org/oransc/ric/portal/dashboard/AppStatsManager.java b/dashboard/webapp-backend/src/main/java/org/oransc/ric/portal/dashboard/AppStatsManager.java
new file mode 100644 (file)
index 0000000..79c75fe
--- /dev/null
@@ -0,0 +1,239 @@
+/*-
+ * ========================LICENSE_START=================================
+ * O-RAN-SC
+ * %%
+ * Copyright (C) 2020 AT&T Intellectual Property
+ * %%
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * 
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ * 
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ========================LICENSE_END===================================
+ */
+package org.oransc.ric.portal.dashboard;
+
+import java.io.File;
+import java.io.IOException;
+import java.lang.invoke.MethodHandles;
+import java.nio.file.Files;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.ListIterator;
+
+import javax.servlet.http.HttpServletResponse;
+
+import org.onap.portalsdk.core.onboarding.exception.PortalAPIException;
+import org.onap.portalsdk.core.restful.domain.EcompUser;
+import org.oransc.ric.portal.dashboard.model.StatsDetailsTransport;
+import org.oransc.ric.portal.dashboard.exception.StatsManagerException;
+import org.oransc.ric.portal.dashboard.model.IDashboardResponse;
+import org.oransc.ric.portal.dashboard.model.AppStats;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+import org.springframework.stereotype.Service;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+/**
+ * Provides simple xApp stats-management services.
+ * 
+ * This first implementation serializes xApp stat details to a file.
+ * 
+ * Migrate to a database someday?
+ */
+
+public class AppStatsManager {
+
+       private static final Logger logger = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+
+       // This default value is only useful for development and testing.
+       public static final String STATS_FILE_PATH = "app-stats.json";
+
+       private final File statsFile;
+       private final List<AppStats> stats;
+       private int appMaxId = -1;
+
+       /**
+        * Development/test-only constructor that uses default file path.
+        * 
+        * @param clear If true, start empty and remove any existing file.
+        * 
+        * @throws IOException On file error
+        */
+       public AppStatsManager(boolean clear) throws IOException {
+               this(STATS_FILE_PATH);
+               if (clear) {
+                       logger.debug("ctor: removing file {}", statsFile.getAbsolutePath());
+                       File f = new File(AppStatsManager.STATS_FILE_PATH);
+                       if (f.exists())
+                               Files.delete(f.toPath());
+                       stats.clear();
+               }
+       }
+
+       /**
+        * Constructor that accepts a file path
+        * 
+        * @param statsFilePath File path
+        * @throws IOException If file cannot be read
+        */
+       public AppStatsManager(final String statsFilePath) throws IOException {
+               logger.debug("ctor: statsfile {}", statsFilePath);
+               if (statsFilePath == null)
+                       throw new IllegalArgumentException("Missing or empty stats file property");
+               statsFile = new File(statsFilePath);
+               logger.debug("ctor: managing stats in file {}", statsFile.getAbsolutePath());
+               if (statsFile.exists()) {
+                       final ObjectMapper mapper = new ObjectMapper();
+                       stats = mapper.readValue(statsFile, new TypeReference<List<AppStats>>() {
+                       });
+                       for (AppStats st: stats) {
+                               if (st.getStatsDetails().getAppId()>appMaxId) 
+                                       appMaxId = st.getStatsDetails().getAppId();
+                       }
+               } else {
+                       stats = new ArrayList<>();
+               }
+       }
+
+       /**
+        * Gets the current app metric stats.
+        * 
+        * @return List of App stat objects, possibly empty
+        */
+       public List<AppStats> getStats() {
+               return this.stats;
+       }
+
+       /**
+        * Gets the current app metric stats by instance key.
+        * 
+        * @param instanceKey Desired instance key
+        * @return List of App stat objects by instance key, possibly empty
+        */
+       public List<AppStats> getStatsByInstance(String instanceKey) {
+               List<AppStats> statsByInstance = new ArrayList<AppStats>();
+               for (AppStats st : this.stats) {
+                       if (st.getInstanceKey().equals(instanceKey)) {
+                               logger.debug("getStatsByInstance: match on instance key {}", instanceKey);
+                               statsByInstance.add(st);
+                       }
+               }
+               return statsByInstance;
+       }
+
+       /**
+        * Gets the stats with the specified app Id and instance key
+        * 
+        * @param appId       Desired app Id
+        * @param instanceKey Desired instance key
+        * @return Stats object; null if Id is not known
+        */
+       public AppStats getStatsById(String instanceKey, int appId) {
+
+               for (AppStats st : this.stats) {
+                       if (st.getInstanceKey().equals(instanceKey) && st.getStatsDetails().getAppId() == appId) {
+                               logger.debug("getStatsById: match on app id {} with instance key {}", appId, instanceKey);
+                               return st;
+                       }
+               }
+               logger.debug("getStatsById: no match on app id with instance key {}{}", appId, instanceKey);
+               return null;
+
+       }
+
+       private void saveStats() throws IOException {
+               final ObjectMapper mapper = new ObjectMapper();
+               mapper.writeValue(statsFile, stats);
+       }
+
+       /*
+        * Allow at most one thread to create a stats at one time.
+        * Before creating new stat, checks for composite key (appname,url) uniqueness for an instance key
+        */
+       public synchronized AppStats createStats(String instanceKey, StatsDetailsTransport statsSetupRequest)
+                       throws StatsManagerException, IOException {
+               logger.debug("createStats: appId {}, instanceKey {}", statsSetupRequest.getAppId(), instanceKey);
+
+               for (AppStats st : stats) {
+                       if (st.getInstanceKey().equals(instanceKey)
+                                       && st.getStatsDetails().getAppName().equals(statsSetupRequest.getAppName())
+                                       && st.getStatsDetails().getMetricUrl().equals(statsSetupRequest.getMetricUrl())) {
+                               String msg = "App exists with name " + statsSetupRequest.getAppName() + " and url "+statsSetupRequest.getMetricUrl()+ " on instance key " + instanceKey;
+                               logger.warn(msg);
+                               throw new StatsManagerException(msg);
+                       }
+               }
+
+               AppStats newAppStat = null;
+               //Assigns appId to be 1 more than the largest value stored in memory
+               appMaxId = appMaxId+1;
+               newAppStat = new AppStats(instanceKey,
+                               new StatsDetailsTransport(appMaxId, statsSetupRequest.getAppName(), statsSetupRequest.getMetricUrl()));
+               stats.add(newAppStat);
+               saveStats();
+               return newAppStat;
+       }
+
+       /*
+        * Allow at most one thread to modify a stats at one time. We still have
+        * last-edit-wins of course.
+        */
+       public synchronized void updateStats(String instanceKey, StatsDetailsTransport statsSetupRequest)
+                       throws StatsManagerException, IOException {
+               logger.debug("updateStats: appId {}, instanceKey {}", statsSetupRequest.getAppId(), instanceKey);
+               boolean editStatsObjectFound = false;
+
+               for (AppStats st : stats) {
+                       if (st.getInstanceKey().equals(instanceKey)
+                                       && st.getStatsDetails().getAppId() == statsSetupRequest.getAppId()) {
+                               AppStats newAppStat = new AppStats(instanceKey, statsSetupRequest);
+                               stats.remove(st);
+                               stats.add(newAppStat);
+                               editStatsObjectFound = true;
+                               saveStats();
+                               break;
+                       }
+               }
+               if (!editStatsObjectFound) {
+                       String msg = "Stats to be updated does not exist ";
+                       logger.warn(msg);
+                       throw new StatsManagerException(msg);
+               }
+       }
+
+       public synchronized AppStats deleteStats(String instanceKey, int appId) throws StatsManagerException, IOException {
+               logger.debug("deleteStats: appId {}, instanceKey {}", appId, instanceKey);
+               boolean deleteStatsObjectFound = false;
+               AppStats stat = null;
+               for (AppStats st : stats) {
+                       if (st.getInstanceKey().equals(instanceKey) && st.getStatsDetails().getAppId() == appId) {
+                               stat = st;
+                               stats.remove(stat);
+                               deleteStatsObjectFound = true;
+                               try {
+                                       saveStats();
+                                       break;
+                               } catch (Exception e) {
+                                       throw new StatsManagerException(e.toString());
+                               }
+
+                       }
+               }
+               if (!deleteStatsObjectFound) {
+                       String msg = "deleteStats: no match on app id {} of instance key {}";
+                       logger.warn(msg, appId, instanceKey);
+                       throw new StatsManagerException(msg);
+               }
+               return stat;
+       }
+}
index 6f124ed..a96567f 100644 (file)
@@ -28,11 +28,9 @@ public abstract class DashboardConstants {
        public static final String ENDPOINT_PREFIX = "/api";
        // Spring path parameters
        public static final String RIC_INSTANCE_KEY = "ric";
+       public static final String APP_ID = "appid";
        // Factor out method names used in multiple controllers
        public static final String VERSION_METHOD = "version";
-       // Apps with metric panels
-       public static final String APP_NAME_MC = "MC";
-       public static final String APP_NAME_ML = "ML";
        // The role names are defined by ONAP Portal.
        // The prefix "ROLE_" is required by Spring.
        // These are used in Java code annotations that require constants.
index 696d74f..c728e7c 100644 (file)
@@ -23,6 +23,7 @@ import java.io.IOException;
 import java.lang.invoke.MethodHandles;
 
 import org.oransc.ric.portal.dashboard.DashboardUserManager;
+import org.oransc.ric.portal.dashboard.AppStatsManager;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -32,7 +33,7 @@ import org.springframework.context.annotation.Configuration;
 import org.springframework.context.annotation.Profile;
 
 /**
- * Creates an instance of the user manager.
+ * Creates an instance of the user manager and app stats manager.
  */
 @Configuration
 @Profile("!test")
@@ -42,11 +43,16 @@ public class AdminConfiguration {
 
        // Populated by the autowired constructor
        private final String userfile;
+       private final String statsfile;
 
        @Autowired
-       public AdminConfiguration(@Value("${userfile}") final String userfile) {
+       public AdminConfiguration(@Value("${userfile}") final String userfile,
+                       @Value("${statsfile}") final String statsfile) {
                logger.debug("ctor userfile '{}'", userfile);
+               logger.debug("ctor statsfile '{}'", statsfile);
                this.userfile = userfile;
+               this.statsfile = statsfile;
+
        }
 
        @Bean
@@ -55,4 +61,10 @@ public class AdminConfiguration {
                return new DashboardUserManager(userfile);
        }
 
+       @Bean
+       // The bean (method) name must be globally unique
+       public AppStatsManager statsManager() throws IOException {
+               return new AppStatsManager(statsfile);
+       }
+
 }
index 1285a0b..62e9886 100644 (file)
  */
 package org.oransc.ric.portal.dashboard.controller;
 
+import java.io.IOException;
 import java.lang.invoke.MethodHandles;
+import java.util.ArrayList;
 import java.util.List;
 
 import org.onap.portalsdk.core.restful.domain.EcompUser;
 import org.oransc.ric.portal.dashboard.DashboardApplication;
 import org.oransc.ric.portal.dashboard.DashboardConstants;
 import org.oransc.ric.portal.dashboard.DashboardUserManager;
+import org.oransc.ric.portal.dashboard.AppStatsManager;
+import org.oransc.ric.portal.dashboard.exception.StatsManagerException;
+import org.oransc.ric.portal.dashboard.model.IDashboardResponse;
+import org.oransc.ric.portal.dashboard.model.StatsDetailsTransport;
 import org.oransc.ric.portal.dashboard.model.RicRegion;
 import org.oransc.ric.portal.dashboard.model.RicRegionList;
 import org.oransc.ric.portal.dashboard.model.RicRegionTransport;
+import org.oransc.ric.portal.dashboard.model.AppStats;
 import org.oransc.ric.portal.dashboard.model.SuccessTransport;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -38,7 +45,12 @@ import org.springframework.http.HttpStatus;
 import org.springframework.http.MediaType;
 import org.springframework.http.ResponseEntity;
 import org.springframework.security.access.annotation.Secured;
+import org.springframework.web.bind.annotation.DeleteMapping;
 import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
 import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RequestParam;
 import org.springframework.web.bind.annotation.RestController;
@@ -61,9 +73,7 @@ public class AdminController {
        public static final String USER_METHOD = "user";
        public static final String VERSION_METHOD = DashboardConstants.VERSION_METHOD;
        public static final String XAPPMETRICS_METHOD = "metrics";
-
-       @Value("${metrics.url.mc}")
-       private String mcAppMetricsUrl;
+       public static final String STATAPPMETRIC_METHOD = "appmetric";
 
        @Value("${metrics.url.ml}")
        private String mlAppMetricsUrl;
@@ -71,6 +81,9 @@ public class AdminController {
        @Autowired
        private DashboardUserManager dashboardUserManager;
 
+       @Autowired
+       private AppStatsManager appStatsManager;
+
        @Autowired
        private RicRegionList instanceConfig;
 
@@ -109,19 +122,54 @@ public class AdminController {
                return instanceConfig.getSimpleInstances();
        }
 
-       @ApiOperation(value = "Gets the kibana metrics URL for the specified app.", response = SuccessTransport.class)
-       @GetMapping(XAPPMETRICS_METHOD)
+       @ApiOperation(value = "Gets all xApp statistics reporting details.", response = StatsDetailsTransport.class, responseContainer = "List")
+       @GetMapping(DashboardConstants.RIC_INSTANCE_KEY + "/{" + DashboardConstants.RIC_INSTANCE_KEY + "}/"
+                       + STATAPPMETRIC_METHOD)
+       @Secured({ DashboardConstants.ROLE_ADMIN, DashboardConstants.ROLE_STANDARD })
+       public List<AppStats> getStats(@PathVariable(DashboardConstants.RIC_INSTANCE_KEY) String instanceKey) {
+               logger.debug("getStats for instance {}", instanceKey);
+               return appStatsManager.getStatsByInstance(instanceKey);
+       }
+
+       @ApiOperation(value = "Gets a xApp's metrics status by Id.", response = StatsDetailsTransport.class, responseContainer = "List")
+       @GetMapping(DashboardConstants.RIC_INSTANCE_KEY + "/{" + DashboardConstants.RIC_INSTANCE_KEY + "}/"
+                       + STATAPPMETRIC_METHOD + "/" + DashboardConstants.APP_ID + "/{" + DashboardConstants.APP_ID + "}")
        @Secured({ DashboardConstants.ROLE_ADMIN, DashboardConstants.ROLE_STANDARD })
-       public ResponseEntity<Object> getAppMetricsUrl(@RequestParam String app) {
-               String metricsUrl = null;
-               if (DashboardConstants.APP_NAME_MC.equals(app))
-                       metricsUrl = mcAppMetricsUrl;
-               else if (DashboardConstants.APP_NAME_ML.equals(app))
-                       metricsUrl = mlAppMetricsUrl;
-               logger.debug("getAppMetricsUrl: app {} metricsurl {}", app, metricsUrl);
-               if (metricsUrl != null)
-                       return new ResponseEntity<>(new SuccessTransport(HttpStatus.OK.ordinal(), metricsUrl), HttpStatus.OK);
-               return ResponseEntity.badRequest().body("Client provided app name is invalid as: " + app);
+       public AppStats getStatsById(@PathVariable(DashboardConstants.RIC_INSTANCE_KEY) String instanceKey,
+                       @PathVariable(DashboardConstants.APP_ID) int appId) {
+               logger.debug("getStatsById for instance {} by app id {}", instanceKey, appId);
+               return appStatsManager.getStatsById(instanceKey, appId);
        }
 
-}
+       @ApiOperation(value = "Creates xApp metrics status.")
+       @PostMapping(DashboardConstants.RIC_INSTANCE_KEY + "/{" + DashboardConstants.RIC_INSTANCE_KEY + "}/"
+                       + STATAPPMETRIC_METHOD)
+       @Secured({ DashboardConstants.ROLE_ADMIN })
+       public IDashboardResponse createStats(@PathVariable(DashboardConstants.RIC_INSTANCE_KEY) String instanceKey,
+                       @RequestBody StatsDetailsTransport statsSetupRequest) throws StatsManagerException, IOException {
+               logger.debug("createStats with instance {} request {}", instanceKey, statsSetupRequest);
+               return appStatsManager.createStats(instanceKey, statsSetupRequest);
+       }
+
+       @ApiOperation(value = "Updates xApp metrics status.")
+       @PutMapping(DashboardConstants.RIC_INSTANCE_KEY + "/{" + DashboardConstants.RIC_INSTANCE_KEY + "}/"
+                       + STATAPPMETRIC_METHOD)
+       @Secured({ DashboardConstants.ROLE_ADMIN })
+       public ResponseEntity<String> updateStats(@PathVariable(DashboardConstants.RIC_INSTANCE_KEY) String instanceKey,
+                       @RequestBody StatsDetailsTransport statsSetupRequest) throws StatsManagerException, IOException {
+               logger.debug("updateStats for instance {} request {}", instanceKey, statsSetupRequest);
+               appStatsManager.updateStats(instanceKey, statsSetupRequest);
+               return ResponseEntity.ok(null);
+       }
+
+       @ApiOperation(value = "Deletes xApp metric status.")
+       @DeleteMapping(DashboardConstants.RIC_INSTANCE_KEY + "/{" + DashboardConstants.RIC_INSTANCE_KEY + "}/"
+                       + STATAPPMETRIC_METHOD + "/" + DashboardConstants.APP_ID + "/{" + DashboardConstants.APP_ID + "}")
+       @Secured({ DashboardConstants.ROLE_ADMIN })
+       public ResponseEntity<String> deleteStats(@PathVariable(DashboardConstants.RIC_INSTANCE_KEY) String instanceKey,
+                       @PathVariable(DashboardConstants.APP_ID) int appId) throws StatsManagerException, IOException {
+               logger.debug("deleteStats instance {} request {}", instanceKey, appId);
+               appStatsManager.deleteStats(instanceKey, appId);
+               return ResponseEntity.ok(null);
+       }
+}
\ No newline at end of file
index e75dfcb..2f08806 100644 (file)
@@ -21,6 +21,7 @@ package org.oransc.ric.portal.dashboard.controller;
 
 import java.lang.invoke.MethodHandles;
 
+import org.oransc.ric.portal.dashboard.exception.StatsManagerException;
 import org.oransc.ric.portal.dashboard.exception.UnknownInstanceException;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -106,5 +107,21 @@ public class CustomResponseEntityExceptionHandler extends ResponseEntityExceptio
                                ex.toString());
                return ResponseEntity.badRequest().body(getShortExceptionMessage(ex));
        }
+       
+       /**
+        * Logs a warning if a StatsManagerException is thrown.
+        * 
+        * @param ex
+        *                    The exception
+        * @param request
+        *                    The original request
+        * @return A response entity with status code 400 and an unstructured message.
+        */
+       @ExceptionHandler({ StatsManagerException.class })
+       public final ResponseEntity<String> handleStatsManagerException(Exception ex, WebRequest request) {
+               log.warn("handleStatsManagerException: request {}, exception {}", request.getDescription(false),
+                               ex.toString());
+               return ResponseEntity.badRequest().body(getShortExceptionMessage(ex));
+       }
 
 }
diff --git a/dashboard/webapp-backend/src/main/java/org/oransc/ric/portal/dashboard/exception/StatsManagerException.java b/dashboard/webapp-backend/src/main/java/org/oransc/ric/portal/dashboard/exception/StatsManagerException.java
new file mode 100644 (file)
index 0000000..c5386c6
--- /dev/null
@@ -0,0 +1,33 @@
+/*-
+ * ========================LICENSE_START=================================
+ * O-RAN-SC
+ * %%
+ * Copyright (C) 2019 - 2020 AT&T Intellectual Property
+ * %%
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * 
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ * 
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ========================LICENSE_END===================================
+ */
+package org.oransc.ric.portal.dashboard.exception;
+
+public class StatsManagerException extends Exception {
+
+       private static final long serialVersionUID = -3887065423512216019L;
+
+       public StatsManagerException() {
+               super();
+       }
+
+       public StatsManagerException(String s) {
+               super(s);
+       }
+}
diff --git a/dashboard/webapp-backend/src/main/java/org/oransc/ric/portal/dashboard/model/AppStats.java b/dashboard/webapp-backend/src/main/java/org/oransc/ric/portal/dashboard/model/AppStats.java
new file mode 100644 (file)
index 0000000..6710cf6
--- /dev/null
@@ -0,0 +1,57 @@
+/*-
+ * ========================LICENSE_START=================================
+ * O-RAN-SC
+ * %%
+ * Copyright (C) 2020 AT&T Intellectual Property
+ * %%
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * 
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ * 
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ========================LICENSE_END===================================
+ */
+package org.oransc.ric.portal.dashboard.model;
+
+public class AppStats implements IDashboardResponse {
+       private String instanceKey;
+       private StatsDetailsTransport statsDetails;
+
+       public AppStats() {
+               super();
+       }
+
+       public AppStats(String instanceKey, StatsDetailsTransport statsDetails) {
+               super();
+               this.instanceKey = instanceKey;
+               this.statsDetails = statsDetails;
+       }
+
+       public StatsDetailsTransport getStatsDetails() {
+               return statsDetails;
+       }
+
+       public void setStatsDetails(StatsDetailsTransport statsDetails) {
+               this.statsDetails = statsDetails;
+       }
+
+       public String getInstanceKey() {
+               return instanceKey;
+       }
+
+       public void setInstanceKey(String instanceKey) {
+               this.instanceKey = instanceKey;
+       }
+
+       @Override
+       public String toString() {
+               return this.getClass().getSimpleName() + "[instance=" + instanceKey + ", statsDetails=" + statsDetails + "]";
+       }
+
+}
diff --git a/dashboard/webapp-backend/src/main/java/org/oransc/ric/portal/dashboard/model/StatsDetailsTransport.java b/dashboard/webapp-backend/src/main/java/org/oransc/ric/portal/dashboard/model/StatsDetailsTransport.java
new file mode 100644 (file)
index 0000000..d79644e
--- /dev/null
@@ -0,0 +1,66 @@
+/*-
+ * ========================LICENSE_START=================================
+ * O-RAN-SC
+ * %%
+ * Copyright (C) 2020 AT&T Intellectual Property
+ * %%
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * 
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ * 
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ========================LICENSE_END===================================
+ */
+package org.oransc.ric.portal.dashboard.model;
+
+public class StatsDetailsTransport implements IDashboardResponse {
+
+       private int appId;
+       private String appName;
+       private String metricUrl;
+
+       public StatsDetailsTransport(int appId, String appName, String metricUrl) {
+               this.appId = appId;
+               this.appName = appName;
+               this.metricUrl = metricUrl;
+       }
+
+       public int getAppId() {
+               return appId;
+       }
+
+       public void setAppId(int appId) {
+               this.appId = appId;
+       }
+
+       public StatsDetailsTransport() {
+       }
+
+       @Override
+       public String toString() {
+               return this.getClass().getName() + "[appId=" + getAppId() + ", appName=" + getAppName() + ", metricUrl="
+                               + getMetricUrl() + "]";
+       }
+
+       public String getAppName() {
+               return appName;
+       }
+
+       public void setAppName(String appName) {
+               this.appName = appName;
+       }
+
+       public String getMetricUrl() {
+               return metricUrl;
+       }
+
+       public void setMetricUrl(String metricUrl) {
+               this.metricUrl = metricUrl;
+       }
+}
index 7c52b99..94dcc44 100644 (file)
 package org.oransc.ric.portal.dashboard.config;
 
 import java.io.IOException;
+import java.io.PrintWriter;
 import java.lang.invoke.MethodHandles;
+import java.util.Collection;
 import java.util.HashSet;
+import java.util.Locale;
 import java.util.Set;
 
 import org.onap.portalsdk.core.onboarding.exception.PortalAPIException;
 import org.onap.portalsdk.core.restful.domain.EcompRole;
 import org.onap.portalsdk.core.restful.domain.EcompUser;
 import org.oransc.ric.portal.dashboard.DashboardUserManager;
+import org.oransc.ric.portal.dashboard.AppStatsManager;
+import org.oransc.ric.portal.dashboard.exception.StatsManagerException;
+import org.oransc.ric.portal.dashboard.model.AppStats;
+import org.oransc.ric.portal.dashboard.model.StatsDetailsTransport;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -35,8 +42,12 @@ import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.context.annotation.Profile;
 
+import javax.servlet.ServletOutputStream;
+import javax.servlet.http.Cookie;
+import javax.servlet.http.HttpServletResponse;
+
 /**
- * Creates a user manager with mock data.
+ * Creates user manager and stats manager with mock data.
  */
 @Configuration
 @Profile("test")
@@ -73,4 +84,18 @@ public class AdminMockConfiguration {
                return mgr;
        }
 
+       @Bean
+       // The bean (method) name must be globally unique
+       public AppStatsManager statsManager() throws IOException, StatsManagerException {
+               logger.debug("statsManager: adding mock data");
+               AppStatsManager mgr = new AppStatsManager(true);
+               String instanceKey = RICInstanceMockConfiguration.INSTANCE_KEY_1;
+               StatsDetailsTransport statsDetails = new StatsDetailsTransport();
+               statsDetails.setAppId(0);
+               statsDetails.setAppName("MachLearn");
+               statsDetails.setMetricUrl("https://www.example.com");
+               mgr.createStats(instanceKey, statsDetails);
+               return mgr;
+       }
+
 }
index 152d14b..aa92e20 100644 (file)
@@ -21,22 +21,27 @@ package org.oransc.ric.portal.dashboard.controller;
 
 import java.lang.invoke.MethodHandles;
 import java.net.URI;
-import java.util.HashMap;
 import java.util.List;
-import java.util.Map;
-
 import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Order;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestMethodOrder;
+import org.junit.jupiter.api.MethodOrderer;
 import org.onap.portalsdk.core.restful.domain.EcompUser;
 import org.oransc.ric.portal.dashboard.DashboardConstants;
+import org.oransc.ric.portal.dashboard.config.RICInstanceMockConfiguration;
+import org.oransc.ric.portal.dashboard.model.AppStats;
 import org.oransc.ric.portal.dashboard.model.RicInstanceKeyName;
+import org.oransc.ric.portal.dashboard.model.StatsDetailsTransport;
 import org.oransc.ric.portal.dashboard.model.SuccessTransport;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.core.ParameterizedTypeReference;
+import org.springframework.http.HttpEntity;
 import org.springframework.http.HttpMethod;
 import org.springframework.http.ResponseEntity;
 
+@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
 public class AdminControllerTest extends AbstractControllerTest {
 
        private static final Logger logger = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
@@ -86,40 +91,78 @@ public class AdminControllerTest extends AbstractControllerTest {
                Assertions.assertTrue(response.getStatusCode().is4xxClientError());
        }
 
+       @Order(1)
        @Test
-       public void getxAppMetricsUrlTest() {
-               Map<String, String> metricsQueryParms = new HashMap<String, String>();
-               URI uri;
+       public void getAppStatsTest() {
+               URI uri = buildUri(null, AdminController.CONTROLLER_PATH, DashboardConstants.RIC_INSTANCE_KEY, "i1",
+                               AdminController.STATAPPMETRIC_METHOD);
+               logger.info("Invoking uri {}", uri);
+               ResponseEntity<List<AppStats>> response = testRestTemplateAdminRole().exchange(uri, HttpMethod.GET, null,
+                               new ParameterizedTypeReference<List<AppStats>>() {
+                               });
+               Assertions.assertFalse(response.getBody().isEmpty());
+               Assertions.assertNotEquals(-1, response.getBody().get(0).getStatsDetails().getAppId());
+       }
 
-               metricsQueryParms.clear();
-               metricsQueryParms.put("app", DashboardConstants.APP_NAME_MC);
-               uri = buildUri(metricsQueryParms, AdminController.CONTROLLER_PATH, AdminController.XAPPMETRICS_METHOD);
-               logger.debug("Invoking {}", uri);
-               ResponseEntity<SuccessTransport> successResponse = testRestTemplateStandardRole().exchange(uri, HttpMethod.GET,
-                               null, SuccessTransport.class);
-               Assertions.assertFalse(successResponse.getBody().getData().toString().isEmpty());
-               Assertions.assertTrue(successResponse.getStatusCode().is2xxSuccessful());
+       @Order(2)
+       @Test
+       public void createAppStatsTest() {
+               URI uri = buildUri(null, AdminController.CONTROLLER_PATH, DashboardConstants.RIC_INSTANCE_KEY,
+                               RICInstanceMockConfiguration.INSTANCE_KEY_1, AdminController.STATAPPMETRIC_METHOD);
+               logger.info("Invoking uri {}", uri);
+               StatsDetailsTransport statsDetails = new StatsDetailsTransport();
+               statsDetails.setAppName("MachLearn-2");
+               statsDetails.setMetricUrl("https://www.example2.com");
+               AppStats st = testRestTemplateAdminRole().postForObject(uri, statsDetails, AppStats.class);
+               Assertions.assertFalse(st.getStatsDetails().getAppName().isEmpty());
+               statsDetails.setAppName("MachLearn-2-next");
+               statsDetails.setMetricUrl("https://www.example2-next.com");
+               AppStats stNext = testRestTemplateAdminRole().postForObject(uri, statsDetails, AppStats.class);
+               Assertions.assertTrue(st.getStatsDetails().getAppId() < stNext.getStatsDetails().getAppId());
+       }
 
-               metricsQueryParms.clear();
-               metricsQueryParms.put("app", DashboardConstants.APP_NAME_ML);
-               logger.debug("Invoking {}", uri);
-               successResponse = testRestTemplateStandardRole().exchange(uri, HttpMethod.GET, null, SuccessTransport.class);
-               Assertions.assertFalse(successResponse.getBody().getData().toString().isEmpty());
-               Assertions.assertTrue(successResponse.getStatusCode().is2xxSuccessful());
+       @Order(3)
+       @Test
+       public void updateAppStatsTest() {
+               URI uri = buildUri(null, AdminController.CONTROLLER_PATH, DashboardConstants.RIC_INSTANCE_KEY,
+                               RICInstanceMockConfiguration.INSTANCE_KEY_1, AdminController.STATAPPMETRIC_METHOD);
+               logger.info("Invoking uri {}", uri);
+               ResponseEntity<List<AppStats>> response = testRestTemplateAdminRole().exchange(uri, HttpMethod.GET, null,
+                               new ParameterizedTypeReference<List<AppStats>>() {
+                               });
+               int statToUpdate = 0;
+               if (response.getBody() != null) {
+                       statToUpdate = response.getBody().get(0).getStatsDetails().getAppId();
+               }
+               StatsDetailsTransport statsDetails = new StatsDetailsTransport();
+               statsDetails.setAppId(statToUpdate);
+               statsDetails.setAppName("MachLearn-1");
+               statsDetails.setMetricUrl("https://www.example1.com");
+               HttpEntity<StatsDetailsTransport> entity = new HttpEntity<>(statsDetails);
+               ResponseEntity<String> stringResponse = testRestTemplateAdminRole().exchange(uri, HttpMethod.PUT, entity,
+                               String.class);
+               Assertions.assertTrue(stringResponse.getStatusCode().is2xxSuccessful());
        }
 
+       @Order(4)
        @Test
-       public void getxAppMetricsUrlTestFail() {
-               Map<String, String> metricsQueryParms = new HashMap<String, String>();
-               // Providing a bogus value for application name in query parameter to test
-               // failure
-               metricsQueryParms.put("app", "ABCD");
-               URI uri = buildUri(metricsQueryParms, AdminController.CONTROLLER_PATH, AdminController.XAPPMETRICS_METHOD);
-               logger.debug("Invoking {}", uri);
-               ResponseEntity<String> errorResponse = testRestTemplateStandardRole().exchange(uri, HttpMethod.GET, null,
+       public void deleteAppStatsTest() {
+               URI uri = buildUri(null, AdminController.CONTROLLER_PATH, DashboardConstants.RIC_INSTANCE_KEY,
+                               RICInstanceMockConfiguration.INSTANCE_KEY_1, AdminController.STATAPPMETRIC_METHOD);
+               ResponseEntity<List<AppStats>> response = testRestTemplateAdminRole().exchange(uri, HttpMethod.GET, null,
+                               new ParameterizedTypeReference<List<AppStats>>() {
+                               });
+               int statToDelete = 0;
+               if (response.getBody() != null) {
+                       statToDelete = response.getBody().get(0).getStatsDetails().getAppId();
+               }
+               uri = buildUri(null, AdminController.CONTROLLER_PATH, DashboardConstants.RIC_INSTANCE_KEY,
+                               RICInstanceMockConfiguration.INSTANCE_KEY_1, AdminController.STATAPPMETRIC_METHOD,
+                               DashboardConstants.APP_ID, String.valueOf(statToDelete));
+               logger.info("Invoking uri {}", uri);
+               ResponseEntity<String> stringResponse = testRestTemplateAdminRole().exchange(uri, HttpMethod.DELETE, null,
                                String.class);
-               logger.debug("{}", errorResponse.getBody().toString());
-               Assertions.assertTrue(errorResponse.getStatusCode().is4xxClientError());
+               Assertions.assertTrue(stringResponse.getStatusCode().is2xxSuccessful());
        }
 
        @Test
index 5d5bfdb..7633eac 100644 (file)
@@ -58,3 +58,20 @@ export interface RanDialogFormData {
   ranPort: string;
   ranType: string;
 }
+
+export interface StatsDialogFormData {
+  appName: string;
+  metricUrl: string;
+}
+
+export interface StatsDetails {
+  appId: number;
+  appName: string;
+  metricUrl: string;
+}
+
+export interface AppStats {
+  instanceKey: string
+  statsDetails: StatsDetails;
+}
+
index bae2721..c91ce3e 100644 (file)
@@ -76,6 +76,7 @@ import { RdComponent } from './rd.component';
 import { SidenavListComponent } from './navigation/sidenav-list/sidenav-list.component';
 import { StatCardComponent } from './ui/stat-card/stat-card.component';
 import { StatsComponent } from './stats/stats.component';
+import { StatsDialogComponent } from './stats/stats-dialog.component';
 import { UserComponent } from './user/user.component';
 
 // RD services
@@ -110,6 +111,7 @@ import { UiService } from './services/ui/ui.service';
     SidenavListComponent,
     StatCardComponent,
     StatsComponent,
+    StatsDialogComponent,
     UserComponent,
     InstanceSelectorDialogComponent
   ],
@@ -174,7 +176,8 @@ import { UiService } from './services/ui/ui.service';
     EditDashboardUserDialogComponent,
     ErrorDialogComponent,
     InstanceSelectorDialogComponent,
-    LoadingDialogComponent
+    LoadingDialogComponent,
+    StatsDialogComponent
   ],
   providers: [
     AppMgrService,
index 0862142..5e6a23d 100644 (file)
  * limitations under the License.
  * ========================LICENSE_END===================================
  */
-import { HttpClient, HttpErrorResponse, HttpHeaders, HttpParams } from '@angular/common/http';
+import { HttpClient, HttpErrorResponse, HttpHeaders, HttpParams, HttpResponse } from '@angular/common/http';
 import { Injectable } from '@angular/core';
 import { Observable } from 'rxjs';
 import { DashboardService } from '../dashboard/dashboard.service';
+import { StatsDetails, AppStats } from '../../interfaces/e2-mgr.types';
 
 @Injectable({
     providedIn: 'root'
@@ -29,6 +30,8 @@ import { DashboardService } from '../dashboard/dashboard.service';
 export class StatsService {
 
     private component = 'admin';
+    private appmetricPath = 'appmetric';
+    private appId = 'appid';
 
     baseJSONServerUrl = 'http://localhost:3000';
     dataMetrics = [{}];
@@ -92,6 +95,31 @@ export class StatsService {
         });
     }
 
+    getAppMetrics(instanceKey: string): Observable<Array<StatsDetails>> {
+        const path = this.dashboardSvc.buildPath(this.component, instanceKey, this.appmetricPath);
+        return this.httpClient.get<Array<StatsDetails>>(path);
+      }
+
+    getAppMetricsById(instanceKey: string, appId: number): Observable<AppStats> {
+        const path = this.dashboardSvc.buildPath(this.component, instanceKey, this.appmetricPath, this.appId, appId);
+        return this.httpClient.get<AppStats>(path);
+      }
+
+    setupAppMetrics(instanceKey: string, req: StatsDetails): Observable<HttpResponse<Object>> {
+        const path = this.dashboardSvc.buildPath(this.component, instanceKey,  this.appmetricPath);
+        return this.httpClient.post(path, req, { observe: 'response' });
+      }
+
+    editAppMetrics(instanceKey: string, req: StatsDetails): Observable<HttpResponse<Object>> {
+        const path = this.dashboardSvc.buildPath(this.component, instanceKey,  this.appmetricPath);
+        return this.httpClient.put(path, req, { observe: 'response' });
+    }
+
+    deleteAppMetrics(instanceKey: string, appId: number): Observable<HttpResponse<Object>> {
+        const path = this.dashboardSvc.buildPath(this.component, instanceKey, this.appmetricPath, this.appId, appId);
+        return this.httpClient.delete(path, { observe: 'response' });
+    }
+
     saveConfig(key: string, value: string) {
         if (key === 'jsonURL') {
             this.baseJSONServerUrl = value;
diff --git a/dashboard/webapp-frontend/src/app/stats/stats-datasource.ts b/dashboard/webapp-frontend/src/app/stats/stats-datasource.ts
new file mode 100644 (file)
index 0000000..e9dcda1
--- /dev/null
@@ -0,0 +1,73 @@
+/*-
+ * ========================LICENSE_START=================================
+ * O-RAN-SC
+ * %%
+ * Copyright (C) 2020 AT&T Intellectual Property
+ * %%
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * 
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ * 
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ========================LICENSE_END===================================
+ */
+
+import { CollectionViewer, DataSource } from '@angular/cdk/collections';
+import { HttpErrorResponse } from '@angular/common/http';
+import { Observable } from 'rxjs/Observable';
+import { BehaviorSubject } from 'rxjs/BehaviorSubject';
+import { of } from 'rxjs/observable/of';
+import { catchError, finalize } from 'rxjs/operators';
+import { E2RanDetails, StatsDetails } from '../interfaces/e2-mgr.types';
+import { E2ManagerService } from '../services/e2-mgr/e2-mgr.service';
+import { NotificationService } from '../services/ui/notification.service';
+import { StatsService } from '../services/stats/stats.service';
+
+export class StatsDataSource extends DataSource<StatsDetails> {
+
+  private statsSubject = new BehaviorSubject<StatsDetails[]>([]);
+
+  private loadingSubject = new BehaviorSubject<boolean>(false);
+
+  public loading$ = this.loadingSubject.asObservable();
+
+  public rowCount = 1; // hide footer during intial load
+
+  constructor(private statsService: StatsService,
+    private notificationService: NotificationService) {
+    super();
+  }
+
+  loadTable(instanceKey: string) {
+    this.loadingSubject.next(true);
+    this.statsService.getAppMetrics(instanceKey)
+      .pipe(
+        catchError( (her: HttpErrorResponse) => {
+          console.log('StatsDataSource failed: ' + her.message);
+          this.notificationService.error('Failed to get Stats details: ' + her.message);
+          return of([]);
+        }),
+        finalize( () =>  this.loadingSubject.next(false) )
+      )
+      .subscribe( (stat: StatsDetails[] ) => {
+        this.rowCount = stat.length;
+        this.statsSubject.next(stat);
+      });
+  }
+
+  connect(collectionViewer: CollectionViewer): Observable<StatsDetails[]> {
+    return this.statsSubject.asObservable();
+  }
+
+  disconnect(collectionViewer: CollectionViewer): void {
+    this.statsSubject.complete();
+    this.loadingSubject.complete();
+  }
+
+}
diff --git a/dashboard/webapp-frontend/src/app/stats/stats-dialog.component.html b/dashboard/webapp-frontend/src/app/stats/stats-dialog.component.html
new file mode 100644 (file)
index 0000000..11479ab
--- /dev/null
@@ -0,0 +1,40 @@
+<!--
+  ========================LICENSE_START=================================
+  O-RAN-SC
+  %%
+  Copyright (C) 2019 AT&T Intellectual Property
+  %%
+  Licensed under the Apache License, Version 2.0 (the "License");
+  you may not use this file except in compliance with the License.
+  You may obtain a copy of the License at
+  
+       http://www.apache.org/licenses/LICENSE-2.0
+  
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+  ========================LICENSE_END===================================
+  -->
+
+<div mat-dialog-title>
+    App Metrics Visualization
+</div>
+<form [formGroup]="statsDialogForm" novalidate autocomplete="off" (ngSubmit)="setupStats(statsDialogForm.value)">
+  <div mat-dialog-content>
+    <mat-form-field class="input-display-block">
+      <input matInput type="text" placeholder="App Name" formControlName="appName">
+      <mat-hint align="end">Example: MC</mat-hint>
+      <mat-error *ngIf="validateControl('appName') && hasError('appName', 'required')">App Name is required</mat-error>
+    </mat-form-field>
+    <mat-form-field class="input-display-block">
+      <input matInput type="text" placeholder="Metrics Url" formControlName="metricUrl">
+      <mat-error *ngIf="validateControl('metricUrl') && hasError('metricUrl', 'required')">Metrics Url is required</mat-error>
+    </mat-form-field>
+  </div>
+  <div mat-dialog-actions class="modal-footer justify-content-center">
+    <button class="mat-raised-button" [mat-dialog-close]="false">Cancel</button>
+    <button class="mat-raised-button mat-primary" [disabled]="!statsDialogForm.valid || processing">OK</button>
+  </div>
+</form>
diff --git a/dashboard/webapp-frontend/src/app/stats/stats-dialog.component.scss b/dashboard/webapp-frontend/src/app/stats/stats-dialog.component.scss
new file mode 100644 (file)
index 0000000..a0dd7c8
--- /dev/null
@@ -0,0 +1,26 @@
+/*-
+ * ========================LICENSE_START=================================
+ * O-RAN-SC
+ * %%
+ * Copyright (C) 2019 AT&T Intellectual Property
+ * %%
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * 
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ * 
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ========================LICENSE_END===================================
+ */
+
+ /* used to place form fields on separate lines/rows in dialog */
+.input-display-block {
+  display: block;
+}
+
+
diff --git a/dashboard/webapp-frontend/src/app/stats/stats-dialog.component.ts b/dashboard/webapp-frontend/src/app/stats/stats-dialog.component.ts
new file mode 100644 (file)
index 0000000..5077a59
--- /dev/null
@@ -0,0 +1,114 @@
+/*-
+ * ========================LICENSE_START=================================
+ * O-RAN-SC
+ * %%
+ * Copyright (C) 2020 AT&T Intellectual Property
+ * %%
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ========================LICENSE_END===================================
+ */
+import { HttpErrorResponse, HttpResponse } from '@angular/common/http';
+import { Component, Inject, OnInit } from '@angular/core';
+import { FormControl, FormGroup } from '@angular/forms';
+import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
+import { Observable } from 'rxjs';
+import { finalize } from 'rxjs/operators';
+import { StatsDialogFormData, StatsDetails } from '../interfaces/e2-mgr.types';
+import { ErrorDialogService } from '../services/ui/error-dialog.service';
+import { LoadingDialogService } from '../services/ui/loading-dialog.service';
+import { NotificationService } from '../services/ui/notification.service';
+import { StatsService } from '../services/stats/stats.service';
+
+@Component({
+  selector: 'stats-dialog',
+  templateUrl: './stats-dialog.component.html',
+  styleUrls: ['./stats-dialog.component.scss']
+})
+
+export class StatsDialogComponent implements OnInit {
+
+  public statsDialogForm: FormGroup;
+  public processing = false;
+
+  constructor(
+    private dialogRef: MatDialogRef<StatsDialogComponent>,
+    private service: StatsService,
+    private errorService: ErrorDialogService,
+    private loadingDialogService: LoadingDialogService,
+    private notifService: NotificationService,
+    @Inject(MAT_DIALOG_DATA) private data) {
+    // opens with empty fields; accepts no data to display
+  }
+
+  ngOnInit() {
+    this.statsDialogForm = new FormGroup({
+      appName: this.data? new FormControl(this.data.appName) : new FormControl(''),
+      metricUrl: this.data? new FormControl(this.data.metricUrl) : new FormControl('')
+    });
+  }
+
+  setupStats = (statsFormValue: StatsDialogFormData) => {
+    if (!this.statsDialogForm.valid) {
+      console.log('Invalid dialog form data, appname and/or metrics url failed to pass its validation checks');
+      return;
+    }
+    this.processing = true;
+    const setupRequest: StatsDetails = {
+      appId: this.data.appId,
+      appName: statsFormValue.appName.trim(),
+      metricUrl: statsFormValue.metricUrl.trim()
+    };
+    this.loadingDialogService.startLoading('Setting up app metrics list');
+    let observable: Observable<HttpResponse<Object>>;
+    if(!(this.data.isEdit==='true'))
+      observable = this.service.setupAppMetrics(this.data.instanceKey, setupRequest);
+    else
+      observable = this.service.editAppMetrics(this.data.instanceKey, setupRequest);
+    observable
+      .pipe(
+        finalize(() => this.loadingDialogService.stopLoading())
+      )
+      .subscribe(
+        (response: any) => {
+          this.processing = false;
+          this.notifService.success('App metrics setup!');
+          this.dialogRef.close(true);
+        },
+        ((her: HttpErrorResponse) => {
+          this.processing = false;
+          // the error field carries the server's response
+          let msg = her.message;
+          if (her.error && her.error.message) {
+            msg = her.error.message;
+          }
+          this.errorService.displayError('App Metrics setup request failed: ' + msg);
+          // keep the dialog open
+        })
+      );
+  }
+
+  hasError(controlName: string, errorName: string) {
+    if (this.statsDialogForm.controls[controlName].hasError(errorName)) {
+      return true;
+    }
+    return false;
+  }
+
+  validateControl(controlName: string) {
+    if (this.statsDialogForm.controls[controlName].invalid && this.statsDialogForm.controls[controlName].touched) {
+      return true;
+    }
+    return false;
+  }
+
+}
index 798d557..4a2ddc9 100644 (file)
@@ -7,9 +7,9 @@
   Licensed under the Apache License, Version 2.0 (the "License");
   you may not use this file except in compliance with the License.
   You may obtain a copy of the License at
-  
+
        http://www.apache.org/licenses/LICENSE-2.0
-  
+
   Unless required by applicable law or agreed to in writing, software
   distributed under the License is distributed on an "AS IS" BASIS,
   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
   limitations under the License.
   ========================LICENSE_END===================================
   -->
-<div class="stats__section">
-
-       <h3 class="rd-global-page-title">Platform stats</h3>
-
-       <mat-tab-group>
-               <mat-tab label="MC xApp">
-                       <mat-grid-list cols="3" rowHeight="3:2">
-                               <mat-grid-tile>
-                                       <mat-card>
-                                               <iframe [src]="metricsUrlMc"></iframe>
-                                       </mat-card>
-                               </mat-grid-tile>
-                       </mat-grid-list>
-               </mat-tab>
-               <mat-tab label="ML xApp">
-                       <mat-grid-list cols="3" rowHeight="3:2">
-                               <mat-grid-tile>
-                                       <mat-card>
-                                               <iframe [src]="metricsUrlMl"></iframe>
-                                       </mat-card>
-                               </mat-grid-tile>
-                       </mat-grid-list>
-               </mat-tab>
-       </mat-tab-group>
-
-</div>
+  <div class="stats__section">
+    <h3 class="rd-global-page-title">Platform stats</h3>
+    <button mat-raised-button (click)="setupAppMetrics()">Add App Metrics..</button>
+    <table mat-table class="stats-table mat-elevation-z8" [dataSource]="dataSource">
+      <ng-template #noValue></ng-template>
+
+
+      <ng-container matColumnDef="appName">
+        <mat-header-cell *matHeaderCellDef>App Name</mat-header-cell>
+        <mat-cell *matCellDef="let stats">
+          <div *ngIf="stats.statsDetails.appName; else noValue">{{stats.statsDetails.appName}}</div>
+        </mat-cell>
+      </ng-container>
+
+      <ng-container matColumnDef="metricUrl">
+        <mat-header-cell *matHeaderCellDef>Metrics Url</mat-header-cell>
+        <mat-cell *matCellDef="let stats">
+          <div *ngIf="stats.statsDetails.metricUrl; else noValue">{{stats.statsDetails.metricUrl}}</div>
+        </mat-cell>
+      </ng-container>
+
+      <ng-container matColumnDef="editmetricUrl">
+        <mat-header-cell *matHeaderCellDef>Action</mat-header-cell>
+        <mat-cell *matCellDef="let stats">
+          <div *ngIf="stats; else noValue"><button mat-icon-button (click)="editAppMetrics(stats)">
+            <mat-icon matTooltip="Edit name, url">edit</mat-icon>
+          </button></div>
+          <div *ngIf="stats; else noValue"><button mat-icon-button (click)="viewAppMetrics(stats)">
+            <mat-icon matTooltip="View metrics">pageview</mat-icon>
+          </button></div>
+          <div *ngIf="stats; else noValue"><button mat-icon-button (click)="deleteAppMetrics(stats)">
+            <mat-icon matTooltip="Delete metrics">delete</mat-icon>
+          </button></div>
+        </mat-cell>
+      </ng-container>
+
+      <ng-container matColumnDef="noRecordsFound">
+        <mat-footer-cell *matFooterCellDef>No records found.</mat-footer-cell>
+      </ng-container>
+
+      <mat-header-row *matHeaderRowDef="displayedColumns"></mat-header-row>
+      <mat-row *matRowDef="let row; columns: displayedColumns"></mat-row>
+      <mat-footer-row *matFooterRowDef="['noRecordsFound']" [ngClass]="{'display-none': dataSource.rowCount > 0}">
+      </mat-footer-row>
+
+    </table>
+    <div class="spinner-container" *ngIf="dataSource.loading$ | async">
+      <mat-spinner diameter=50></mat-spinner>
+    </div>
+
+    <mat-form-field [hidden]="true">
+      <input matInput type="number" [formControl]="selected">
+    </mat-form-field>
+
+    <mat-tab-group [selectedIndex]="selected.value" (selectedIndexChange)="selected.setValue($event)" (selectedTabChange)="onTabChanged($event)">
+      <mat-tab *ngFor="let tab of tabs; let index = index" [label]="tab.statsDetails.appName">
+        <iframe [src]="metricsUrl"></iframe>
+      </mat-tab>
+    </mat-tab-group>
+  </div>
index 5cfc7ab..aa2bbe1 100644 (file)
  .stats__section {
 }
 
+.stats-table {
+  width: 100%;
+  min-height: 150px;
+  margin-top: 10px;
+  background-color:transparent;
+}
+
+.spinner-container mat-spinner {
+  margin: 130px auto 0 auto;
+}
+
 .bar-chart-card {
     height: 100%;
     width: 100%;
@@ -39,4 +50,19 @@ iframe {
   width: 500px;
   height: 420px;
   border: 1px solid black;
-}
\ No newline at end of file
+}
+
+.display-none {
+  display: none;
+}
+
+.mat-column-editmetricUrl {
+  white-space: unset ;
+  max-width: 18% ;
+}
+
+.mat-column-appName {
+  white-space: unset ;
+  max-width: 28% ;
+}
+
index 2993056..1737651 100644 (file)
@@ -7,9 +7,9 @@
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
  * You may obtain a copy of the License at
- * 
+ *
  *      http://www.apache.org/licenses/LICENSE-2.0
- * 
+ *
  * Unless required by applicable law or agreed to in writing, software
  * distributed under the License is distributed on an "AS IS" BASIS,
  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  * limitations under the License.
  * ========================LICENSE_END===================================
  */
-import { Component, OnInit, ViewChildren, QueryList } from '@angular/core';
-import { BaseChartDirective } from 'ng2-charts/ng2-charts';
+import { Component, OnInit } from '@angular/core';
 import { StatsService } from '../services/stats/stats.service';
-import { HttpClient } from '@angular/common/http';
-import { DashboardSuccessTransport } from '../interfaces/dashboard.types';
+import { ConfirmDialogService } from '../services/ui/confirm-dialog.service';
 import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
+import { MatDialog, MatTabChangeEvent } from '@angular/material';
+import { NotificationService } from '../services/ui/notification.service';
+import { UiService } from '../services/ui/ui.service';
+import { InstanceSelectorService } from '../services/instance-selector/instance-selector.service';
+import { StatsDataSource } from './stats-datasource';
+import { Subscription } from 'rxjs';
+import { StatsDialogComponent } from './stats-dialog.component';
+import { AppStats } from '../interfaces/e2-mgr.types';
+import {FormControl} from '@angular/forms';
+import { RicInstance} from '../interfaces/dashboard.types';
 
 @Component({
     selector: 'rd-stats',
@@ -31,23 +39,138 @@ import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
 })
 export class StatsComponent implements OnInit {
 
-    @ViewChildren(BaseChartDirective) charts: QueryList<BaseChartDirective>;
     checked = false;
-    metricsUrlMc: SafeResourceUrl;
-    metricsUrlMl: SafeResourceUrl;
+    darkMode: boolean;
+    panelClass: string;
+    displayedColumns: string[] = ['appName', 'metricUrl', 'editmetricUrl'];
+    dataSource: StatsDataSource;
+    private instanceChange: Subscription;
+    private instanceKey: string;
+    metricsUrl: SafeResourceUrl;
+    tabs = [];
+    showTabs = false;
+    selected = new FormControl(0);
 
-    constructor(private service: StatsService,
-        private httpClient: HttpClient,
-        private sanitize: DomSanitizer) {
+    constructor(private statsservice: StatsService,
+        private sanitize: DomSanitizer,
+        private confirmDialogService: ConfirmDialogService,
+        private notificationService: NotificationService,
+        public instanceSelectorService: InstanceSelectorService,
+        public dialog: MatDialog,
+        public ui: UiService) {
     }
 
     ngOnInit() {
-        this.service.getAppMetricsUrl('MC').subscribe((res: DashboardSuccessTransport) => {
-            this.metricsUrlMc = this.sanitize.bypassSecurityTrustResourceUrl(res.data);
+        this.dataSource = new StatsDataSource(this.statsservice, this.notificationService);
+
+        this.ui.darkModeState.subscribe((isDark) => {
+            this.darkMode = isDark;
+        });
+
+        this.instanceChange = this.instanceSelectorService.getSelectedInstance().subscribe((instance: RicInstance) => {
+            if (instance.key) {
+                this.instanceKey = instance.key;
+                this.dataSource.loadTable(instance.key);
+              }
         });
-        this.service.getAppMetricsUrl('ML').subscribe((res: DashboardSuccessTransport) => {
-            this.metricsUrlMl = this.sanitize.bypassSecurityTrustResourceUrl(res.data);
+
+    }
+
+    ngOnDestroy() {
+        this.instanceChange.unsubscribe();
+    }
+
+    setupAppMetrics() {
+        if (this.darkMode) {
+          this.panelClass = 'dark-theme';
+        } else {
+          this.panelClass = '';
+        }
+        const dialogRef = this.dialog.open(StatsDialogComponent, {
+          panelClass: this.panelClass,
+          width: '450px',
+          data: {
+            instanceKey: this.instanceKey
+          }
         });
+        dialogRef.afterClosed()
+          .subscribe((result: boolean) => {
+            if (result) {
+              this.dataSource.loadTable(this.instanceKey);
+            }
+          });
     }
 
+    editAppMetrics(stats?) {
+        const dialogRef = this.dialog.open(StatsDialogComponent, {
+          hasBackdrop: false,
+          data: {
+            instanceKey: this.instanceKey,
+            appName: stats.statsDetails.appName ? stats.statsDetails.appName : '',
+            metricUrl: stats.statsDetails.metricUrl ? stats.statsDetails.metricUrl : '',
+            appId: stats.statsDetails.appId ? stats.statsDetails.appId : 0,
+            isEdit: 'true'
+          }
+        });
+        dialogRef.afterClosed()
+          .subscribe((result: boolean) => {
+            if (result) {
+              this.dataSource.loadTable(this.instanceKey);
+            }
+          });
+      }
+
+      viewAppMetrics(stats?) {
+        this.statsservice.getAppMetricsById(this.instanceKey, stats.statsDetails.appId)  .subscribe((res: AppStats) => {
+          this.metricsUrl = this.sanitize.bypassSecurityTrustResourceUrl(res.statsDetails.metricUrl);
+          let tabNotThere:boolean = true;
+          if (this.tabs.length <= 0) {
+            this.tabs.push(res);
+            this.selected.setValue(this.tabs.length - 1);
+          }
+          else {
+            for(let i=0; i<this.tabs.length; i++){
+              if (this.tabs[i].statsDetails.appId == res.statsDetails.appId) {
+                this.tabs[i].statsDetails.appName = res.statsDetails.appName;
+                this.tabs[i].statsDetails.metricUrl = res.statsDetails.metricUrl;
+                this.selected.setValue(i);
+                tabNotThere  = false;
+                break;
+              }
+            }
+            if (tabNotThere) {
+              this.tabs.push(res);
+              this.selected.setValue(this.tabs.length - 1);
+            }
+          }
+        });
+      }
+
+      onTabChanged(event: MatTabChangeEvent) {
+        if (event.index>=0)
+          this.viewAppMetrics(this.tabs[event.index]);
+      }
+
+      deleteAppMetrics(stats?) {
+        this.confirmDialogService.openConfirmDialog('Are you sure you want to delete this entry?')
+      .afterClosed().subscribe((res: boolean) => {
+        if (res) {
+
+        this.statsservice.deleteAppMetrics(this.instanceKey, stats.statsDetails.appId).subscribe(() => {
+            for(let i=0; i<this.tabs.length; i++){
+              if (this.tabs[i].instanceKey === this.instanceKey && this.tabs[i].statsDetails.appId == stats.statsDetails.appId) {
+                this.tabs.splice(i, 1);
+                if (this.tabs.length>0) {
+                  if (this.tabs[i] == null)
+                    i=i-1;
+                  this.viewAppMetrics(this.tabs[i]);
+                }
+                break;
+              }
+            }
+            this.dataSource.loadTable(this.instanceKey);
+        });
+      }
+    });
+  }
 }
index 6468354..91ad3d0 100644 (file)
@@ -13,6 +13,7 @@ Version 2.0.1, 17 Mar 2020
 * Drop ENDC and X2 setup requests to E2 Manager
 * Upgrade to Spring-Boot 2.2.4.RELEASE
 * Set the first instance as the default one
+* Add methods to create, update and delete xApp stat metric URLs
 
 Version 2.0.0, 5 Feb 2020
 --------------------------