From c0398e31cc04e7d5aa2076c525741d4207e5e0c8 Mon Sep 17 00:00:00 2001 From: Anandarup Sarkar Date: Tue, 28 Jan 2020 15:40:08 -0500 Subject: [PATCH] App metrics visualization manage Signed-off-by: Anandarup Sarkar Signed-off-by: Lott, Christopher (cl778h) Change-Id: I213c2161c5b8ef26376d00e67f01b9274bc216df --- .gitignore | 3 + .../ric/portal/dashboard/AppStatsManager.java | 239 +++++++++++++++++++++ .../ric/portal/dashboard/DashboardConstants.java | 4 +- .../dashboard/config/AdminConfiguration.java | 16 +- .../dashboard/controller/AdminController.java | 80 +++++-- .../CustomResponseEntityExceptionHandler.java | 17 ++ .../dashboard/exception/StatsManagerException.java | 33 +++ .../ric/portal/dashboard/model/AppStats.java | 57 +++++ .../dashboard/model/StatsDetailsTransport.java | 66 ++++++ .../dashboard/config/AdminMockConfiguration.java | 27 ++- .../dashboard/controller/AdminControllerTest.java | 103 ++++++--- .../src/app/interfaces/e2-mgr.types.ts | 17 ++ dashboard/webapp-frontend/src/app/rd.module.ts | 5 +- .../src/app/services/stats/stats.service.ts | 30 ++- .../src/app/stats/stats-datasource.ts | 73 +++++++ .../src/app/stats/stats-dialog.component.html | 40 ++++ .../src/app/stats/stats-dialog.component.scss | 26 +++ .../src/app/stats/stats-dialog.component.ts | 114 ++++++++++ .../src/app/stats/stats.component.html | 90 +++++--- .../src/app/stats/stats.component.scss | 28 ++- .../src/app/stats/stats.component.ts | 155 +++++++++++-- docs/release-notes.rst | 1 + 22 files changed, 1125 insertions(+), 99 deletions(-) create mode 100644 dashboard/webapp-backend/src/main/java/org/oransc/ric/portal/dashboard/AppStatsManager.java create mode 100644 dashboard/webapp-backend/src/main/java/org/oransc/ric/portal/dashboard/exception/StatsManagerException.java create mode 100644 dashboard/webapp-backend/src/main/java/org/oransc/ric/portal/dashboard/model/AppStats.java create mode 100644 dashboard/webapp-backend/src/main/java/org/oransc/ric/portal/dashboard/model/StatsDetailsTransport.java create mode 100644 dashboard/webapp-frontend/src/app/stats/stats-datasource.ts create mode 100644 dashboard/webapp-frontend/src/app/stats/stats-dialog.component.html create mode 100644 dashboard/webapp-frontend/src/app/stats/stats-dialog.component.scss create mode 100644 dashboard/webapp-frontend/src/app/stats/stats-dialog.component.ts diff --git a/.gitignore b/.gitignore index 675d063c..5cd11dc2 100644 --- a/.gitignore +++ b/.gitignore @@ -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 index 00000000..79c75fe8 --- /dev/null +++ b/dashboard/webapp-backend/src/main/java/org/oransc/ric/portal/dashboard/AppStatsManager.java @@ -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 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>() { + }); + 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 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 getStatsByInstance(String instanceKey) { + List statsByInstance = new ArrayList(); + 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; + } +} diff --git a/dashboard/webapp-backend/src/main/java/org/oransc/ric/portal/dashboard/DashboardConstants.java b/dashboard/webapp-backend/src/main/java/org/oransc/ric/portal/dashboard/DashboardConstants.java index 6f124ed8..a96567f7 100644 --- a/dashboard/webapp-backend/src/main/java/org/oransc/ric/portal/dashboard/DashboardConstants.java +++ b/dashboard/webapp-backend/src/main/java/org/oransc/ric/portal/dashboard/DashboardConstants.java @@ -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. diff --git a/dashboard/webapp-backend/src/main/java/org/oransc/ric/portal/dashboard/config/AdminConfiguration.java b/dashboard/webapp-backend/src/main/java/org/oransc/ric/portal/dashboard/config/AdminConfiguration.java index 696d74f6..c728e7c0 100644 --- a/dashboard/webapp-backend/src/main/java/org/oransc/ric/portal/dashboard/config/AdminConfiguration.java +++ b/dashboard/webapp-backend/src/main/java/org/oransc/ric/portal/dashboard/config/AdminConfiguration.java @@ -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); + } + } diff --git a/dashboard/webapp-backend/src/main/java/org/oransc/ric/portal/dashboard/controller/AdminController.java b/dashboard/webapp-backend/src/main/java/org/oransc/ric/portal/dashboard/controller/AdminController.java index 1285a0b6..62e98863 100644 --- a/dashboard/webapp-backend/src/main/java/org/oransc/ric/portal/dashboard/controller/AdminController.java +++ b/dashboard/webapp-backend/src/main/java/org/oransc/ric/portal/dashboard/controller/AdminController.java @@ -19,16 +19,23 @@ */ 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 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 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 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 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 diff --git a/dashboard/webapp-backend/src/main/java/org/oransc/ric/portal/dashboard/controller/CustomResponseEntityExceptionHandler.java b/dashboard/webapp-backend/src/main/java/org/oransc/ric/portal/dashboard/controller/CustomResponseEntityExceptionHandler.java index e75dfcb1..2f08806e 100644 --- a/dashboard/webapp-backend/src/main/java/org/oransc/ric/portal/dashboard/controller/CustomResponseEntityExceptionHandler.java +++ b/dashboard/webapp-backend/src/main/java/org/oransc/ric/portal/dashboard/controller/CustomResponseEntityExceptionHandler.java @@ -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 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 index 00000000..c5386c65 --- /dev/null +++ b/dashboard/webapp-backend/src/main/java/org/oransc/ric/portal/dashboard/exception/StatsManagerException.java @@ -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 index 00000000..6710cf6e --- /dev/null +++ b/dashboard/webapp-backend/src/main/java/org/oransc/ric/portal/dashboard/model/AppStats.java @@ -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 index 00000000..d79644e4 --- /dev/null +++ b/dashboard/webapp-backend/src/main/java/org/oransc/ric/portal/dashboard/model/StatsDetailsTransport.java @@ -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; + } +} diff --git a/dashboard/webapp-backend/src/test/java/org/oransc/ric/portal/dashboard/config/AdminMockConfiguration.java b/dashboard/webapp-backend/src/test/java/org/oransc/ric/portal/dashboard/config/AdminMockConfiguration.java index 7c52b99d..94dcc446 100644 --- a/dashboard/webapp-backend/src/test/java/org/oransc/ric/portal/dashboard/config/AdminMockConfiguration.java +++ b/dashboard/webapp-backend/src/test/java/org/oransc/ric/portal/dashboard/config/AdminMockConfiguration.java @@ -20,14 +20,21 @@ 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; + } + } diff --git a/dashboard/webapp-backend/src/test/java/org/oransc/ric/portal/dashboard/controller/AdminControllerTest.java b/dashboard/webapp-backend/src/test/java/org/oransc/ric/portal/dashboard/controller/AdminControllerTest.java index 152d14b2..aa92e202 100644 --- a/dashboard/webapp-backend/src/test/java/org/oransc/ric/portal/dashboard/controller/AdminControllerTest.java +++ b/dashboard/webapp-backend/src/test/java/org/oransc/ric/portal/dashboard/controller/AdminControllerTest.java @@ -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 metricsQueryParms = new HashMap(); - 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> response = testRestTemplateAdminRole().exchange(uri, HttpMethod.GET, null, + new ParameterizedTypeReference>() { + }); + 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 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> response = testRestTemplateAdminRole().exchange(uri, HttpMethod.GET, null, + new ParameterizedTypeReference>() { + }); + 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 entity = new HttpEntity<>(statsDetails); + ResponseEntity stringResponse = testRestTemplateAdminRole().exchange(uri, HttpMethod.PUT, entity, + String.class); + Assertions.assertTrue(stringResponse.getStatusCode().is2xxSuccessful()); } + @Order(4) @Test - public void getxAppMetricsUrlTestFail() { - Map metricsQueryParms = new HashMap(); - // 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 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> response = testRestTemplateAdminRole().exchange(uri, HttpMethod.GET, null, + new ParameterizedTypeReference>() { + }); + 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 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 diff --git a/dashboard/webapp-frontend/src/app/interfaces/e2-mgr.types.ts b/dashboard/webapp-frontend/src/app/interfaces/e2-mgr.types.ts index 5d5bfdbb..7633eac7 100644 --- a/dashboard/webapp-frontend/src/app/interfaces/e2-mgr.types.ts +++ b/dashboard/webapp-frontend/src/app/interfaces/e2-mgr.types.ts @@ -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; +} + diff --git a/dashboard/webapp-frontend/src/app/rd.module.ts b/dashboard/webapp-frontend/src/app/rd.module.ts index bae27212..c91ce3e9 100644 --- a/dashboard/webapp-frontend/src/app/rd.module.ts +++ b/dashboard/webapp-frontend/src/app/rd.module.ts @@ -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, diff --git a/dashboard/webapp-frontend/src/app/services/stats/stats.service.ts b/dashboard/webapp-frontend/src/app/services/stats/stats.service.ts index 08621423..5e6a23d2 100644 --- a/dashboard/webapp-frontend/src/app/services/stats/stats.service.ts +++ b/dashboard/webapp-frontend/src/app/services/stats/stats.service.ts @@ -17,10 +17,11 @@ * 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> { + const path = this.dashboardSvc.buildPath(this.component, instanceKey, this.appmetricPath); + return this.httpClient.get>(path); + } + + getAppMetricsById(instanceKey: string, appId: number): Observable { + const path = this.dashboardSvc.buildPath(this.component, instanceKey, this.appmetricPath, this.appId, appId); + return this.httpClient.get(path); + } + + setupAppMetrics(instanceKey: string, req: StatsDetails): Observable> { + const path = this.dashboardSvc.buildPath(this.component, instanceKey, this.appmetricPath); + return this.httpClient.post(path, req, { observe: 'response' }); + } + + editAppMetrics(instanceKey: string, req: StatsDetails): Observable> { + const path = this.dashboardSvc.buildPath(this.component, instanceKey, this.appmetricPath); + return this.httpClient.put(path, req, { observe: 'response' }); + } + + deleteAppMetrics(instanceKey: string, appId: number): Observable> { + 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 index 00000000..e9dcda18 --- /dev/null +++ b/dashboard/webapp-frontend/src/app/stats/stats-datasource.ts @@ -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 { + + private statsSubject = new BehaviorSubject([]); + + private loadingSubject = new BehaviorSubject(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 { + 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 index 00000000..11479ab0 --- /dev/null +++ b/dashboard/webapp-frontend/src/app/stats/stats-dialog.component.html @@ -0,0 +1,40 @@ + + +
+ App Metrics Visualization +
+
+
+ + + Example: MC + App Name is required + + + + Metrics Url is required + +
+ +
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 index 00000000..a0dd7c85 --- /dev/null +++ b/dashboard/webapp-frontend/src/app/stats/stats-dialog.component.scss @@ -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 index 00000000..5077a596 --- /dev/null +++ b/dashboard/webapp-frontend/src/app/stats/stats-dialog.component.ts @@ -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, + 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>; + 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; + } + +} diff --git a/dashboard/webapp-frontend/src/app/stats/stats.component.html b/dashboard/webapp-frontend/src/app/stats/stats.component.html index 798d557a..4a2ddc91 100644 --- a/dashboard/webapp-frontend/src/app/stats/stats.component.html +++ b/dashboard/webapp-frontend/src/app/stats/stats.component.html @@ -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. @@ -17,29 +17,63 @@ limitations under the License. ========================LICENSE_END=================================== --> -
- -

Platform stats

- - - - - - - - - - - - - - - - - - - - - - -
+
+

Platform stats

+ + + + + + + App Name + +
{{stats.statsDetails.appName}}
+
+
+ + + Metrics Url + +
{{stats.statsDetails.metricUrl}}
+
+
+ + + Action + +
+
+
+
+
+ + + No records found. + + + + + + + +
+
+ +
+ + + + + + + + + + +
diff --git a/dashboard/webapp-frontend/src/app/stats/stats.component.scss b/dashboard/webapp-frontend/src/app/stats/stats.component.scss index 5cfc7ab9..aa2bbe1f 100644 --- a/dashboard/webapp-frontend/src/app/stats/stats.component.scss +++ b/dashboard/webapp-frontend/src/app/stats/stats.component.scss @@ -20,6 +20,17 @@ .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% ; +} + diff --git a/dashboard/webapp-frontend/src/app/stats/stats.component.ts b/dashboard/webapp-frontend/src/app/stats/stats.component.ts index 29930566..17376510 100644 --- a/dashboard/webapp-frontend/src/app/stats/stats.component.ts +++ b/dashboard/webapp-frontend/src/app/stats/stats.component.ts @@ -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. @@ -17,12 +17,20 @@ * 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; 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=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; i0) { + if (this.tabs[i] == null) + i=i-1; + this.viewAppMetrics(this.tabs[i]); + } + break; + } + } + this.dataSource.loadTable(this.instanceKey); + }); + } + }); + } } diff --git a/docs/release-notes.rst b/docs/release-notes.rst index 64683541..91ad3d02 100644 --- a/docs/release-notes.rst +++ b/docs/release-notes.rst @@ -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 -------------------------- -- 2.16.6