diff --git a/Source/Applications/SystemCenter/App.config b/Source/Applications/SystemCenter/App.config index 2ab27e946..0d3b05c07 100644 --- a/Source/Applications/SystemCenter/App.config +++ b/Source/Applications/SystemCenter/App.config @@ -39,7 +39,6 @@ - diff --git a/Source/Applications/SystemCenter/AppDebug.config b/Source/Applications/SystemCenter/AppDebug.config index 6d067ac16..a631b9982 100644 --- a/Source/Applications/SystemCenter/AppDebug.config +++ b/Source/Applications/SystemCenter/AppDebug.config @@ -35,7 +35,6 @@ - diff --git a/Source/Applications/SystemCenter/Model/DeviceHealthReport.cs b/Source/Applications/SystemCenter/Model/DeviceHealthReport.cs index 5d13f8b9d..bbcce2b79 100644 --- a/Source/Applications/SystemCenter/Model/DeviceHealthReport.cs +++ b/Source/Applications/SystemCenter/Model/DeviceHealthReport.cs @@ -25,20 +25,20 @@ using GSF.Data.Model; using GSF.Web.Model; using Newtonsoft.Json; -using Newtonsoft.Json.Linq; using openXDA.APIAuthentication; using System; using System.Collections.Generic; using System.Data; using System.Linq; using System.Net.Http; +using System.Text; +using System.Text.RegularExpressions; using System.Web.Http; -using SystemCenter.Controllers; -using System.Collections.Generic; +using System.Windows.Forms.DataVisualization.Charting; namespace SystemCenter.Model { - [CustomView(@" + [CustomView(@" SELECT Meter.ID, Meter.Name, @@ -51,9 +51,9 @@ namespace SystemCenter.Model afvtsc.Value as TSC, afvsector.Value as Sector, afvip.Value as IP, - bds.BadDays, - mimdstat.BadDays as MiMDBadDays, - xdastat.BadDays as XDABadDays, + ISNULL(bds.BadDays, 0) as BadDays, + ISNULL(mimdstat.BadDays, 0) as MiMDBadDays, + ISNULL(xdastat.BadDays, 0) as XDABadDays, mimdstat.[Status] as MiMDStatus, xdastat.[Status] as XDAStatus, dqstat.[Status] as DQStatus, @@ -83,121 +83,302 @@ SELECT MAX(BadDays) as BadDays FROM ( SELECT TOP 1 BadDays FROM [MiMDDailyStatistic] WHERE Meter.AssetKey = [MiMDDailyStatistic].Meter ORDER BY Cast([MiMDDailyStatistic].Date as date) DESC ) t ) as bds "), SettingsCategory("systemSettings"), ViewOnly, AllowSearch] - public class DeviceHealthReport - { - public int ID { get; set; } - public string Name { get; set; } - public string Model { get; set; } - public string TimeZone { get; set; } - public string OpenMIC { get; set; } - public int LocationID { get; set; } - public string Substation { get; set; } - public string LocationKey { get; set; } - public string TSC { get; set; } - public string Sector { get; set; } - public string IP { get; set; } - public DateTime LastGood { get; set; } // no longer gotten from table - public int BadDays { get; set; } - public int MiMDBadDays { get; set; } - public int MICBadDays { get; set; } // no longer gotten from table - public int XDABadDays { get; set; } - public string MiMDStatus { get; set; } + public class DeviceHealthReport + { + public int ID { get; set; } + public string Name { get; set; } + public string Model { get; set; } + public string TimeZone { get; set; } + public string OpenMIC { get; set; } + public int LocationID { get; set; } + public string Substation { get; set; } + public string LocationKey { get; set; } + public string TSC { get; set; } + public string Sector { get; set; } + public string IP { get; set; } + public DateTime? LastGood { get; set; } // no longer gotten from table + public int BadDays { get; set; } + public int MiMDBadDays { get; set; } + public int? MICBadDays { get; set; } // no longer gotten from table + public int XDABadDays { get; set; } + public string MiMDStatus { get; set; } public string MICStatus { get; set; } // no longer gotten from table - public string XDAStatus { get; set; } - public DateTime LastConfigChange { get; set; } - } - - [RoutePrefix("api/DeviceHealthReport")] - public class DeviceHealthReportController : ModelController - { - public class StatusItem - { - public string Status { get; set; } - public string Description { get; set; } - } - - public class AppStatus - { - public string Status { get; set; } - - public List Details { get; set; } - - } - - public override IHttpActionResult GetSearchableList([FromBody] PostData postData) + public string XDAStatus { get; set; } + public DateTime? LastConfigChange { get; set; } + } + + [RoutePrefix("api/DeviceHealthReport")] + public class DeviceHealthReportController : ModelController + { + public int PagingAmount { get; set; } = 50; + public class DailyStatisticsRecord { - int warningLevel = 50; - int errorLevel = 100; - DataTable table = GetSearchResults(postData); - - // add empty rows to table for openMIC info - table.Columns.Add("MICStatus"); - table.Columns.Add("MICBadDays", Type.GetType("System.Int32")); - table.Columns.Add("LastGood"); - foreach (DataRow devHealthReport in table.Rows) - { - if (string.IsNullOrEmpty(devHealthReport.ConvertField("OpenMic"))) - { - continue; - } - var rawResponse = ControllerHelpers.Get("OpenMIC", $"api/Operations/Statistics/{devHealthReport["OpenMIC"]}"); - JObject? openMicResult = null; - try - { - openMicResult = JObject.Parse(rawResponse); - } - catch(JsonReaderException) - { - continue; - } - int totalUnsuccessfulConnections = openMicResult["TotalUnsuccessfulConnections"]?.ToObject() ?? 0; - if (totalUnsuccessfulConnections > errorLevel) - { - devHealthReport["Status"] = "Error"; + [PrimaryKey(true)] + public int ID { get; set; } + + public DateTime Timestamp { get; set; } + + public int BadDays { get; set; } + + public string Meter { get; set; } + + [FieldDataType(DbType.DateTime2, DatabaseType.SQLServer)] + public DateTime? LastSuccessfulConnection { get; set; } + + [FieldDataType(DbType.DateTime2, DatabaseType.SQLServer)] + public DateTime? LastUnsuccessfulConnection { get; set; } + + public string LastUnsuccessfulConnectionExplanation { get; set; } + + [NonRecordField] + public int TotalConnections => TotalSuccessfulConnections + TotalUnsuccessfulConnections; + + public int TotalUnsuccessfulConnections { get; set; } + + public int TotalSuccessfulConnections { get; set; } + } + + public class StatusItem + { + public string Status { get; set; } + public string Description { get; set; } + } + + public class AppStatus + { + public string Status { get; set; } + + public List Details { get; set; } + + } + + public override IHttpActionResult GetPagedList([FromBody] PostData postData, int page) + { + PagedResults pagedReports = new() + { + RecordsPerPage = 50 + }; + + PostData openMicRequestBody = new() + { + Searches = [], + Ascending = true, + OrderBy = "Timestamp" + }; + PostData systemCenterRequestBody = new() + { + Searches = [], + Ascending = true, + OrderBy = "Name" + }; + + SQLSearchFilter[] badDayFilters = []; + + // for each filter, add it to the request body that would use it + foreach (SQLSearchFilter filter in postData.Searches) + { + if (filter.FieldName == "LastGood") + { + SQLSearchFilter newFilter = new() + { + FieldName = "LastSuccessfulConnection", + SearchText = filter.SearchText, + Operator = filter.Operator, + }; + openMicRequestBody.Searches = openMicRequestBody.Searches.Append(newFilter); + continue; } - else if (totalUnsuccessfulConnections > warningLevel) + if (filter.FieldName == "MICStatus") { - devHealthReport["Status"] = "Warning"; + if (filter.SearchText == "(Warning)") + { + SQLSearchFilter minFilter = new() + { + FieldName = "TotalUnsuccessfulConnections", + Operator = ">=", + SearchText = WarningLevel.ToString() + }; + SQLSearchFilter maxFilter = new() + { + FieldName = "TotalUnsuccessfulConnections", + Operator = "<", + SearchText = ErrorLevel.ToString() + }; + openMicRequestBody.Searches = openMicRequestBody.Searches.Append(minFilter); + openMicRequestBody.Searches = openMicRequestBody.Searches.Append(maxFilter); + } + if (filter.SearchText == "(Error)") + { + + SQLSearchFilter errorFilter = new() + { + FieldName = "TotalUnsuccessfulConnections", + Operator = ">=", + SearchText = ErrorLevel.ToString() + }; + openMicRequestBody.Searches = openMicRequestBody.Searches.Append(errorFilter); + } + if (filter.SearchText == "(Warning,Error)") + { + SQLSearchFilter warningFilter = new() + { + FieldName = "TotalUnsuccessfulConnections", + Operator = ">=", + SearchText = WarningLevel.ToString() + }; + openMicRequestBody.Searches = openMicRequestBody.Searches.Append(warningFilter); + } } - devHealthReport["MICStatus"] = openMicResult["Status"]; - devHealthReport["MICBadDays"] = 0; // just a placeholder for now. + if (filter.FieldName == "BadDays") + { + badDayFilters = badDayFilters.Append(filter).ToArray(); + continue; + } + systemCenterRequestBody.Searches = systemCenterRequestBody.Searches.Append(filter); + } + + // pass the sorting argument to the correct application and sort the other by basic field + if (postData.OrderBy == "LastGood") + { + openMicRequestBody.OrderBy = "LastSuccessfulConnection"; + openMicRequestBody.Ascending = postData.Ascending; + } + else if (postData.OrderBy == "MICStatus") + { + openMicRequestBody.OrderBy = "TotalSuccessfulConnections"; + openMicRequestBody.Ascending = postData.Ascending; + } + else + { + systemCenterRequestBody.OrderBy = postData.OrderBy; + systemCenterRequestBody.Ascending = postData.Ascending; + } + + DataTable systemCenterResult = GetSearchResults(systemCenterRequestBody); - if (openMicResult["LastSuccessfulConnection"] != null) - { - devHealthReport["LastGood"] = openMicResult["LastSuccessfulConnection"]; + //turn into array. + DeviceHealthReport[] deviceHealthReports = systemCenterResult.AsEnumerable() + .Select(row => new DeviceHealthReport() + { + ID = row.Field("ID"), + Name = row.Field("Name"), + Model = row.Field("Model"), + TimeZone = row.Field("TimeZone"), + OpenMIC = row.Field("OpenMIC"), + LocationID = row.Field("LocationID"), + Substation = row.Field("Substation"), + LocationKey = row.Field("LocationKey"), + LastGood = null, + TSC = row.Field("TSC"), + Sector = row.Field("Sector"), + IP = row.Field("IP"), + BadDays = row.Field("BadDays"), + MiMDBadDays = row.Field("MiMDBadDays"), + MICStatus = null, + MICBadDays = null, + XDABadDays = row.Field("XDABadDays"), + MiMDStatus = row.Field("MiMDStatus"), + XDAStatus = row.Field("XDAStatus"), + // LastConfigChange = new DateTime(row.Field("LastConfigChange")) + }).ToArray(); + + // get a list of meters to search for in open mic + string[] openMICMeters = deviceHealthReports + .Select(device => device.OpenMIC).Where(openMIC => openMIC != "").ToArray(); + + SQLSearchFilter openMICMeterFilter = new() + { + SearchText = $"({String.Join(",", openMICMeters)})", + Operator = "IN", + FieldName = "Meter" + }; + + openMicRequestBody.Searches.Append(openMICMeterFilter); + + DailyStatisticsRecord[] openMicStatistics; + + void ConfigureRequest(HttpRequestMessage request) + { + request.Method = HttpMethod.Post; + request.Content = new StringContent(JsonConvert.SerializeObject(openMicRequestBody), Encoding.UTF8, "application/json"); + } + try + { + APIQuery apiQuery = GetAPIQuery(); + HttpResponseMessage response = apiQuery.SendWebRequestAsync(ConfigureRequest, $"api/DailyStatistics/SearchableList").Result; + string responseContent = response.Content.ReadAsStringAsync().Result; + string trimmedResponse = responseContent.Trim('"'); + string unescapedResponse = Regex.Unescape(trimmedResponse); + openMicStatistics = JsonConvert.DeserializeObject(unescapedResponse); + } + catch (Exception e) + { + pagedReports.TotalRecords = deviceHealthReports.Count(); + pagedReports.NumberOfPages = (pagedReports.TotalRecords + pagedReports.RecordsPerPage - 1) / pagedReports.RecordsPerPage; + DeviceHealthReport[] pagedRecords = deviceHealthReports.Skip(page * pagedReports.RecordsPerPage).Take(pagedReports.RecordsPerPage).ToArray(); + pagedReports.Data = JsonConvert.SerializeObject(pagedRecords); + return Ok(JsonConvert.SerializeObject(pagedReports)); // return just the system center stuff + } + + DeviceHealthReport[] combinedReport = CombineData(deviceHealthReports, openMicStatistics, postData).ToArray(); + + foreach (SQLSearchFilter badDayFilter in badDayFilters) + { + combinedReport = FilterBadDays(combinedReport, badDayFilter); + } + + if (postData.OrderBy == "BadDays") + { + if (postData.Ascending) + { + combinedReport = combinedReport.OrderBy(record => record.BadDays).ToArray(); } - } - return Ok(JsonConvert.SerializeObject(table)); + else + { + combinedReport = combinedReport.OrderByDescending(record => record.BadDays).ToArray(); + } + } + + pagedReports.TotalRecords = combinedReport.Count(); + pagedReports.NumberOfPages = (pagedReports.TotalRecords + pagedReports.RecordsPerPage - 1) / pagedReports.RecordsPerPage; + DeviceHealthReport[] pageRecords = combinedReport.Skip(page * pagedReports.RecordsPerPage).Take(pagedReports.RecordsPerPage).ToArray(); + pagedReports.Data = JsonConvert.SerializeObject(pageRecords); + return Ok(JsonConvert.SerializeObject(pagedReports)); } - [HttpGet, Route("OpenMICStatus")] + [HttpGet, Route("OpenMICStatus")] public IHttpActionResult GetOpenMICStatus() - { - AppStatus status = new AppStatus() - { - Status ="Success", - Details = [] - }; + { + + void ConfigureRequest(HttpRequestMessage request) + { + request.Method = HttpMethod.Get; + } - HttpResponseMessage? openMICResponse = null; + AppStatus status = new AppStatus() + { + Status = "Success", + Details = [] + }; + HttpResponseMessage? openMICResponse = null; try - { - openMICResponse = GetHealth("OpenMIC", $"api/health/getsystemstatus/"); - - } - catch - { - status.Status = "Error"; - status.Details.Add(new StatusItem() - { - Status = "Error", - Description = "Could not connect to openMIC. Check the OpenMIC.URL setting in System Center Settings." + { + APIQuery apiQuery = GetAPIQuery(); + openMICResponse = apiQuery.SendWebRequestAsync(ConfigureRequest, $"api/health/getsystemstatus/").Result; + } + catch + { + status.Status = "Error"; + status.Details.Add(new StatusItem() + { + Status = "Error", + Description = "Could not connect to openMIC. Check the OpenMIC.URL setting in System Center Settings." }); } - if (openMICResponse is null) + if (openMICResponse is null) return Ok(status); status.Details.Add(new StatusItem() @@ -207,15 +388,15 @@ public IHttpActionResult GetOpenMICStatus() }); if (openMICResponse.StatusCode == System.Net.HttpStatusCode.Unauthorized) - { + { status.Status = "Error"; status.Details.Add(new StatusItem() { Status = "Error", - Description = "Could not authorize with openMIC. Check the OpenMIC.Credential and OpenMIC.Password settings in System Center Settings." - }); + Description = "Could not authorize with openMIC. Check the OpenMIC.Credential and OpenMIC.Password settings in System Center Settings." + }); - return Ok(status); + return Ok(status); } if (openMICResponse.StatusCode == System.Net.HttpStatusCode.NotFound) @@ -248,54 +429,64 @@ public IHttpActionResult GetOpenMICStatus() Description = "Successful authentication." }); - string r = openMICResponse.Content.ReadAsStringAsync().Result; + string r = openMICResponse.Content.ReadAsStringAsync().Result; - StatusItem[] responseStatus = JsonConvert.DeserializeObject(r); + StatusItem[] responseStatus = JsonConvert.DeserializeObject(r); status.Details.AddRange(responseStatus); - if (status.Details.Any(item => item.Status == "Error")) { - status.Status = "Error"; - } + if (status.Details.Any(item => item.Status == "Error")) + { + status.Status = "Error"; + } return Ok(status); } - [HttpGet, Route("ScadaTriggerStatus")] - public IHttpActionResult GetScadaTriggerStatus() - { + [HttpGet, Route("ScadaTriggerStatus")] + public IHttpActionResult GetScadaTriggerStatus() + { AppStatus status = new AppStatus() { Status = "Success", Details = [] }; - HttpResponseMessage openMICResponse = null; - try - { - openMICResponse = GetHealth("OpenMIC", $"api/health/getsystemstatus/"); - } - catch - { - status.Status = "N/A"; - status.Details.Add(new StatusItem() - { - Status = "Error", - Description = "Could not connect to openMIC." - }); - } - - if (openMICResponse is null) - { - return Ok(status); - } - - HttpResponseMessage scadaTriggerResponse = null; - try - { - scadaTriggerResponse = GetHealth("OpenMIC", $"api/health/getscadatriggerhealth/"); - } - catch - { + HttpResponseMessage openMICResponse = null; + + + void ConfigureRequest(HttpRequestMessage request) + { + request.Method = HttpMethod.Get; + } + + try + { + APIQuery apiQuery = GetAPIQuery(); + openMICResponse = apiQuery.SendWebRequestAsync(ConfigureRequest, $"api/health/getsystemstatus/").Result; + } + catch + { + status.Status = "N/A"; + status.Details.Add(new StatusItem() + { + Status = "Error", + Description = "Could not connect to openMIC." + }); + } + + if (openMICResponse is null) + { + return Ok(status); + } + + HttpResponseMessage scadaTriggerResponse = null; + try + { + APIQuery apiQuery = GetAPIQuery(); + scadaTriggerResponse = apiQuery.SendWebRequestAsync(ConfigureRequest, $"api/health/getscadatriggerhealth/").Result; + } + catch + { status.Status = "N/A"; status.Details.Add(new StatusItem() { @@ -304,60 +495,208 @@ public IHttpActionResult GetScadaTriggerStatus() }); } - if (scadaTriggerResponse is null) - { - return Ok(status); - } + if (scadaTriggerResponse is null) + { + return Ok(status); + } - if (scadaTriggerResponse.StatusCode != System.Net.HttpStatusCode.OK) - { - status.Status = "N/A"; - status.Details.Add(new StatusItem() - { - Status = "Error", + if (scadaTriggerResponse.StatusCode != System.Net.HttpStatusCode.OK) + { + status.Status = "N/A"; + status.Details.Add(new StatusItem() + { + Status = "Error", Description = "Could not establish connection to Scada Trigger." - }); + }); - return Ok(status); + return Ok(status); } string r = scadaTriggerResponse.Content.ReadAsStringAsync().Result; StatusItem[] responseStatus = JsonConvert.DeserializeObject(r); - status.Details.AddRange(responseStatus); + status.Details.AddRange(responseStatus); - if (status.Details.Any(item => item.Status == "Error")) { + if (status.Details.Any(item => item.Status == "Error")) + { status.Status = "Error"; } - return Ok(status); - } + return Ok(status); + } - /// - /// Processes Get request from an application using settings table parameters. - /// Exceptions are expected to be handled by the caller. - /// - /// Name of Application - /// Path to specific API request - /// string - public static HttpResponseMessage GetHealth(string application, string requestURI) + [HttpGet, Route("OpenMICMeterStatistics")] + public IHttpActionResult GetOpenMICMeterStatistics([FromUri] string meter) { - using (AdoDataConnection connection = new AdoDataConnection("systemSettings")) + DailyStatisticsRecord[] meterStatistics = []; + OpenMICDailyStatistic[] micDailyStatistics = []; + void ConfigureRequest(HttpRequestMessage request) { - string url = new TableOperations(connection).QueryRecordWhere($"Name = '{application}.Url'")?.Value ?? ""; - string credential = new TableOperations(connection).QueryRecordWhere($"Name = '{application}.Credential'")?.Value ?? ""; - string password = new TableOperations(connection).QueryRecordWhere($"Name = '{application}.Password'")?.Value ?? ""; - - void ConfigureRequest(HttpRequestMessage request) + request.Method = HttpMethod.Get; + } + try + { + APIQuery apiQuery = GetAPIQuery(); + HttpResponseMessage response = apiQuery.SendWebRequestAsync(ConfigureRequest, $"api/DailyStatistics/Get/?meter={meter}").Result; + string responseContent = response.Content.ReadAsStringAsync().Result; + string trimmedResponse = responseContent.Trim('"'); + string unescapedResponse = Regex.Unescape(trimmedResponse); + meterStatistics = JsonConvert.DeserializeObject(unescapedResponse); + foreach (DailyStatisticsRecord record in meterStatistics) { - request.Method = HttpMethod.Get; + micDailyStatistics = micDailyStatistics.Append(new() + { + ID = record.ID, + Date = record.Timestamp.ToString(), + Meter = record.Meter, + LastSuccessfulConnection = record.LastSuccessfulConnection, + LastUnsuccessfulConnection = record.LastUnsuccessfulConnection, + LastUnsuccessfulConnectionExplanation = record.LastUnsuccessfulConnectionExplanation, + TotalConnections = record.TotalConnections, + TotalSuccessfulConnections = record.TotalSuccessfulConnections, + TotalUnsuccessfulConnections = record.TotalUnsuccessfulConnections, + BadDays = record.BadDays, + Status = (record.TotalUnsuccessfulConnections >= 50) ? (record.TotalUnsuccessfulConnections >= 100) ? "Error" : "Warning" : "" + }).ToArray(); } + } + catch (Exception e) + { + return Ok(); + } + return Ok(JsonConvert.SerializeObject(micDailyStatistics)); + } + + public static APIQuery GetAPIQuery() + { + using (AdoDataConnection connection = new AdoDataConnection("systemSettings")) + { + string url = new TableOperations(connection).QueryRecordWhere($"Name = 'OpenMIC.Url'")?.Value ?? ""; + string credential = new TableOperations(connection).QueryRecordWhere($"Name = 'OpenMIC.Credential'")?.Value ?? ""; + string password = new TableOperations(connection).QueryRecordWhere($"Name = 'OpenMIC.Password'")?.Value ?? ""; + //string token = GenerateAntiForgeryToken(application); //return Get(httpClient, url, requestURI, credential, password, token); APIQuery query = new APIQuery(credential, password, url); - return query.SendWebRequestAsync(ConfigureRequest, requestURI).Result; + return query; } } + + private static DeviceHealthReport[] FilterBadDays(DeviceHealthReport[] reports, SQLSearchFilter badDayFilter) + { + DeviceHealthReport[] filteredReports = []; + // Bad Days can be =, <> (neq), <, <=, >, >=, with a number + switch (badDayFilter.Operator) + { + case "=": + filteredReports = reports + .Where(record => record.BadDays == int.Parse(badDayFilter.SearchText)).ToArray(); + break; + case "<>": + filteredReports = reports + .Where(record => record.BadDays != int.Parse(badDayFilter.SearchText)).ToArray(); + break; + case "<": + filteredReports = reports + .Where(record => record.BadDays < int.Parse(badDayFilter.SearchText)).ToArray(); + break; + case "<=": + filteredReports = reports + .Where(record => record.BadDays <= int.Parse(badDayFilter.SearchText)).ToArray(); + break; + case ">": + filteredReports = reports + .Where(record => record.BadDays > int.Parse(badDayFilter.SearchText)).ToArray(); + break; + case ">=": + filteredReports = reports + .Where(record => record.BadDays >= int.Parse(badDayFilter.SearchText)).ToArray(); + break; + default: + break; + } + return filteredReports; + } + + private List CombineData(DeviceHealthReport[] systemCenterData, DailyStatisticsRecord[] openMicData, PostData postData) + { + //Turn into Divtionary using ID + List results = []; + HashSet processedID = new(); + + bool openMICFiltered = (postData.Searches.Any(search => string.Equals(search.FieldName, "LastGood", StringComparison.OrdinalIgnoreCase) + || string.Equals(search.FieldName, "MICStatus", StringComparison.OrdinalIgnoreCase))); + + bool openMICOrdered = (postData.OrderBy == "LastGood" || postData.OrderBy == "MICStatus"); + + if (openMICOrdered) // if sorted or filtered by openMICField + { + foreach (DailyStatisticsRecord record in openMicData) + { + DeviceHealthReport matchedReport = systemCenterData.FirstOrDefault(r => string.Equals(r.OpenMIC, record.Meter, StringComparison.OrdinalIgnoreCase)); + + if (matchedReport is not null) + { + results.Add(CombineReports(matchedReport, record)); + processedID.Add(matchedReport.ID); + } + } + if (!openMICFiltered) // if not filtered by openMIC, add the rest of systemCenter below + results.AddRange(systemCenterData.Where(record => !processedID.Contains(record.ID))); + + } + else + { + foreach (DeviceHealthReport record in systemCenterData) + { + DailyStatisticsRecord? openMicRecord = null; + + if (!string.IsNullOrEmpty(record.OpenMIC)) + openMicRecord = openMicData.FirstOrDefault(r => string.Equals(r.Meter, record.OpenMIC, StringComparison.OrdinalIgnoreCase)); + + if (openMicRecord is null) + { + if (!openMICFiltered) + results.Add(record); + continue; + } + + results.Add(CombineReports(record, openMicRecord)); + } + } + + return results; + } + + private DeviceHealthReport CombineReports(DeviceHealthReport systemCenterRecord, DailyStatisticsRecord? openMICRecord) + { + if (openMICRecord is null) + { + return systemCenterRecord; + } + + switch (openMICRecord.TotalUnsuccessfulConnections) + { + case int n when n > ErrorLevel: + systemCenterRecord.MICStatus = "Error"; + break; + case int n when n > WarningLevel: + systemCenterRecord.MICStatus = "Warning"; + break; + default: + systemCenterRecord.MICStatus = ""; + break; + } + + systemCenterRecord.MICBadDays = Math.Max(openMICRecord.BadDays, systemCenterRecord.BadDays); + + systemCenterRecord.LastGood = openMICRecord.LastSuccessfulConnection; + + return systemCenterRecord; + } + + public int WarningLevel { get; set; } = 50; + public int ErrorLevel { get; set; } = 100; } } \ No newline at end of file diff --git a/Source/Applications/SystemCenter/ScheduledProcesses/OpenMICMeterStatisticOperation.cs b/Source/Applications/SystemCenter/ScheduledProcesses/OpenMICMeterStatisticOperation.cs deleted file mode 100644 index 5da230186..000000000 --- a/Source/Applications/SystemCenter/ScheduledProcesses/OpenMICMeterStatisticOperation.cs +++ /dev/null @@ -1,233 +0,0 @@ -//****************************************************************************************************** -// OpenMICMeterStatisticOperation.cs - Gbtc -// -// Copyright © 2021, Grid Protection Alliance. All Rights Reserved. -// -// Licensed to the Grid Protection Alliance (GPA) under one or more contributor license agreements. See -// the NOTICE file distributed with this work for additional information regarding copyright ownership. -// The GPA licenses this file to you under the MIT License (MIT), the "License"; you may not use this -// file except in compliance with the License. You may obtain a copy of the License at: -// -// http://opensource.org/licenses/MIT -// -// Unless agreed to in writing, the subject software distributed under the License is distributed on an -// "AS-IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. Refer to the -// License for the specific language governing permissions and limitations. -// -// Code Modification History: -// ---------------------------------------------------------------------------------------------------- -// 07/09/2021 - Billy Ernest -// Generated original version of source code. -// -//****************************************************************************************************** - -using GSF.Configuration; -using GSF.Data; -using GSF.Data.Model; -using GSF.Identity; -using log4net; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using openXDA.Model; -using System; -using System.Collections.Generic; -using System.Data; -using System.IO; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Net.Mail; -using System.Security; -using System.Text; -using System.Threading.Tasks; -using SystemCenter.Controllers; -using SystemCenter.Model; -using SystemCenter.Model.Security; -using Setting = SystemCenter.Model.Setting; - -namespace SystemCenter -{ - public class OpenMICMeterStatisticOperation - { - - #region [ Static ] - private static readonly ILog Log = LogManager.GetLogger(typeof(OpenMICMeterStatisticOperation)); - private static bool Running { get; set; } = false; - - #endregion - - #region [ Member ] - #endregion - - #region [ Constructor ] - public OpenMICMeterStatisticOperation() { - } - - #endregion - - #region [ Properties ] - #endregion - - #region [ Methods ] - public void GetStatistics() - { - if (Running) - { - Log.Info("OpenMIC Statistic operation already running..."); - return; - } - - try - { - Log.Info("Beginning OpenMIC Statistic operation"); - - Running = true; - IEnumerable devices = GetOpenMICMeters(); - if(devices == null) - Log.Info("Null devices record recieved from OpenMIC."); - else if (!devices.Any()) - Log.Info("Empty devices record recieved from OpenMIC."); - - foreach (string device in devices) - { - try - { - Log.Info($"Querying {device} for the OpenMIC Statistic operation"); - - JObject statistic = GetOpenMICStatistic(device); - if (statistic == null) { - Log.Info($"No statistics from openMIC for {device}"); - continue; - } - - using (AdoDataConnection connection = new AdoDataConnection("systemSettings")) - { - AdditionalField field = new TableOperations(connection).QueryRecordWhere("ParentTable = 'Meter' AND FieldName = 'OpenMICAcronym'"); - if (field == null) - { - Log.Info($"No Field exists for OpenMICAcronym"); - continue; - } - - AdditionalFieldValue value = new TableOperations(connection).QueryRecordWhere("AdditionalFieldID = {0} AND Value = {1}", field.ID, device); - if (value == null) - { - Log.Info($"No additional value exists for {device}"); - continue; - } - - Meter meter = new TableOperations(connection).QueryRecordWhere("ID = {0}", value.ParentTableID); - if (meter == null) - { - Log.Info($"No meter exists for {device}"); - continue; - } - - int warningLevel = int.Parse(new TableOperations(connection).QueryRecordWhere("Name = 'OpenMIC.WarningLevel'")?.Value ?? "50"); - int errorLevel = int.Parse(new TableOperations(connection).QueryRecordWhere("Name = 'OpenMIC.ErrorLevel'")?.Value ?? "100"); - - openXDA.Model.Setting defaultTimeZone = new TableOperations(connection).QueryRecordWhere("Name = 'System.DefaultMeterTimeZone'"); - if (defaultTimeZone == null) - { - Log.Info($"No setting exists for default time zone, using UTC"); - defaultTimeZone = new openXDA.Model.Setting() { Value = "UTC" }; - } - - if (meter.TimeZone == null) meter.TimeZone = defaultTimeZone.Value; - - string date = TimeZoneInfo.ConvertTimeFromUtc(statistic["EndTime"]?.ToObject() ?? DateTime.UtcNow, TimeZoneInfo.FindSystemTimeZoneById(meter.TimeZone)).ToString("MM/dd/yyyy"); - if (date == "01/01/0001") { - date = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, TimeZoneInfo.FindSystemTimeZoneById(meter.TimeZone)).ToString("MM/dd/yyyy"); - } - - OpenMICDailyStatistic stat = new TableOperations(connection).QueryRecordWhere("Meter = {0} AND Date = {1}", meter.AssetKey, date); - - if (stat == null) - { - stat = new OpenMICDailyStatistic(); - stat.Meter = meter.AssetKey; - stat.Date = date; - stat.BadDays = new TableOperations(connection).QueryRecords("[DATE] DESC", new RecordRestriction("Meter = {0}", meter.AssetKey)).FirstOrDefault()?.BadDays ?? 0; - } - - DateTime? lastSuccess = statistic["LastSuccessfulConnection"].Value(); - if (lastSuccess != null) - lastSuccess = TimeZoneInfo.ConvertTimeFromUtc((DateTime)lastSuccess, TimeZoneInfo.FindSystemTimeZoneById(meter.TimeZone)); - - stat.LastSuccessfulConnection = lastSuccess; - - DateTime? lastUnsuccess = statistic["LastUnsuccessfulConnection"].Value(); - if (lastUnsuccess != null) - lastUnsuccess = TimeZoneInfo.ConvertTimeFromUtc((DateTime)lastUnsuccess, TimeZoneInfo.FindSystemTimeZoneById(meter.TimeZone)); - - stat.LastUnsuccessfulConnection = lastUnsuccess; - stat.LastUnsuccessfulConnectionExplanation = statistic["LastUnsuccessfulConnectionExplanation"]?.ToString(); - stat.TotalSuccessfulConnections = statistic["TotalSuccessfulConnections"]?.ToObject() ?? 0; - stat.TotalUnsuccessfulConnections = statistic["TotalUnsuccessfulConnections"]?.ToObject() ?? 0; - stat.TotalConnections = stat.TotalSuccessfulConnections + stat.TotalUnsuccessfulConnections; - - if (stat.Status == "Error") { } // do nothing if alreaedy an error for the day - else if (stat.TotalUnsuccessfulConnections > errorLevel) - { - stat.Status = "Error"; - stat.BadDays++; - - } - else if (stat.Status == "Warning") { } // do nothing else if already a warning for the day - else if (stat.TotalUnsuccessfulConnections > warningLevel) - { - stat.Status = "Warning"; - } - - Log.Info($"Updating statistic record for {device} - Date: {stat.Date} / ID: {stat.ID} / Last Successful Connection: {stat.LastSuccessfulConnection} / Daily Connections: {stat.TotalConnections}"); - new TableOperations(connection).AddNewOrUpdateRecord(stat); - Log.Info($"Loaded record for {device} with no exceptions"); - } - - } - catch (Exception ex) - { - Log.Error(ex.Message + "\n" + ex.StackTrace, ex); - } - } - - } - catch (Exception ex) { - Log.Error(ex.Message + "\n" + ex.StackTrace, ex); - - } - finally - { - Running = false; - - } - } - - private IEnumerable GetOpenMICMeters() { - using (AdoDataConnection connection = new AdoDataConnection("systemSettings")) - { - DataTable table = connection.RetrieveData(@" - SELECT AdditionalFieldValue.Value - FROM - AdditionalFieldValue JOIN - AdditionalField ON AdditionalFieldValue.AdditionalFieldID = AdditionalField.ID - WHERE - AdditionalField.ParentTable = 'Meter' AND - AdditionalField.FieldName = 'OpenMICAcronym' - - "); - - return table.Select().Select(x => x["Value"].ToString()); - } - //return ControllerHelpers.Get>("OpenMIC", "api/Operations/Meters"); - } - - private JObject GetOpenMICStatistic(string meter) - { - return JObject.Parse(ControllerHelpers.Get("OpenMIC", $"api/Operations/Statistics/{meter}")); - } - - #endregion - } -} \ No newline at end of file diff --git a/Source/Applications/SystemCenter/ServiceHost.cs b/Source/Applications/SystemCenter/ServiceHost.cs index 18e7280f3..eaa561da2 100644 --- a/Source/Applications/SystemCenter/ServiceHost.cs +++ b/Source/Applications/SystemCenter/ServiceHost.cs @@ -158,8 +158,6 @@ public ServiceHost() systemSettings.Add("CompanyAcronym", "GPA", "The acronym representing the company who owns this instance of the SystemCenter."); systemSettings.Add("UserAccountMetaDataUpdater", (systemSettings["CompanyAcronym"].Value == "TVA" ? "0 0 * * *" : "never"), "Default frequency to run the user account meta-data updater"); - systemSettings.Add("OpenMICStatisticOperation", (systemSettings["CompanyAcronym"].Value == "TVA" ? "* * * * *" : "never"), "Default frequency to run openmic statistics operation"); - // Attempt to set default culture string defaultCulture = systemSettings["DefaultCulture"].ValueAs("en-US"); CultureInfo.DefaultThreadCurrentCulture = CultureInfo.CreateSpecificCulture(defaultCulture); // Defaults for date formatting, etc. @@ -263,8 +261,6 @@ private void ServiceHelper_ServiceStarted(object sender, EventArgs e) if (systemSettings["UserAccountMetaDataUpdater"].Value != "never") m_serviceHelper.AddScheduledProcess(UserAccountMetaDataUpdaterHandler, "UserAccountMetaDataUpdater", systemSettings["UserAccountMetaDataUpdater"].Value); - if (systemSettings["OpenMICStatisticOperation"].Value != "never") - m_serviceHelper.AddScheduledProcess(OpenMICStatisticOperationHandler, "OpenMICStatisticOperation", systemSettings["OpenMICStatisticOperation"].Value); m_serviceHelper.ClientRequestHandlers.Add(new ClientRequestHandler("ReloadSystemSettings", "Reloads system settings from the database", ReloadSystemSettingsRequestHandler)); m_serviceHelper.ClientRequestHandlers.Add(new ClientRequestHandler("EngineStatus", "Displays status information about the XDA engine", EngineStatusHandler)); @@ -625,12 +621,6 @@ private void UserAccountMetaDataUpdaterHandler(string s, object[] args) { metaDataUpdater.Update(); } - private void OpenMICStatisticOperationHandler(string s, object[] args) - { - OpenMICMeterStatisticOperation operation = new OpenMICMeterStatisticOperation(); - operation.GetStatistics(); - } - // Reloads system settings from the database. private void ReloadSystemSettingsRequestHandler(ClientRequestInfo requestInfo) { diff --git a/Source/Applications/SystemCenter/SystemCenter.csproj b/Source/Applications/SystemCenter/SystemCenter.csproj index 1b43f3537..23bbdfc0c 100644 --- a/Source/Applications/SystemCenter/SystemCenter.csproj +++ b/Source/Applications/SystemCenter/SystemCenter.csproj @@ -302,7 +302,6 @@ - diff --git a/Source/Applications/SystemCenter/wwwroot/Scripts/TSX/SystemCenter/DeviceHealthReport/AppStatus.tsx b/Source/Applications/SystemCenter/wwwroot/Scripts/TSX/SystemCenter/DeviceHealthReport/AppStatus.tsx index 80dcc5a05..c3dfd359b 100644 --- a/Source/Applications/SystemCenter/wwwroot/Scripts/TSX/SystemCenter/DeviceHealthReport/AppStatus.tsx +++ b/Source/Applications/SystemCenter/wwwroot/Scripts/TSX/SystemCenter/DeviceHealthReport/AppStatus.tsx @@ -87,7 +87,7 @@ const AppStatus = (props: { Name: string, Endpoint: string }) => { > {appStatusData?.Details == null ? <> : appStatusData.Details.map((data, index) => ( -
{GetStatusSymbol(data.Status)} diff --git a/Source/Applications/SystemCenter/wwwroot/Scripts/TSX/SystemCenter/DeviceHealthReport/DeviceHealthReport.tsx b/Source/Applications/SystemCenter/wwwroot/Scripts/TSX/SystemCenter/DeviceHealthReport/DeviceHealthReport.tsx index 994b58ee3..ab3d0eeb6 100644 --- a/Source/Applications/SystemCenter/wwwroot/Scripts/TSX/SystemCenter/DeviceHealthReport/DeviceHealthReport.tsx +++ b/Source/Applications/SystemCenter/wwwroot/Scripts/TSX/SystemCenter/DeviceHealthReport/DeviceHealthReport.tsx @@ -22,7 +22,7 @@ //****************************************************************************************************** import * as React from 'react'; -import { Table, Column } from '@gpa-gemstone/react-table'; +import { Table, Column, Paging } from '@gpa-gemstone/react-table'; import * as _ from 'lodash'; import { Application, SystemCenter } from '@gpa-gemstone/application-typings'; import { SystemCenter as SCGlobal } from '../global'; @@ -32,7 +32,14 @@ import { SystemCenterSettingSlice } from '../Store/Store'; import moment from 'moment'; import { ReactIcons } from '@gpa-gemstone/gpa-symbols'; import AppStatus from './AppStatus' -import { ErrorBoundary } from '@gpa-gemstone/common-pages' +import { ToolTip } from '@gpa-gemstone/react-forms' + +interface IPagedResult { + Data: string, + NumberOfPages: number, + TotalRecords: number, + RecordsPerPage: number +} const defaultSearchcols: Search.IField[] = [ { label: 'Name', key: 'Name', type: 'string', isPivotField: false }, @@ -65,23 +72,27 @@ const DeviceHealthReport: Application.Types.iByComponent = (props) => { const settings = useAppSelector(SystemCenterSettingSlice.Data); const settingStatus = useAppSelector(SystemCenterSettingSlice.Status); + const [hovered, setHovered] = React.useState(null); + const [pagedData, setPagedData] = React.useState(null); + const [page, setPage] = React.useState(0); React.useEffect(() => { let handle = getMeters(); handle.done((dt: string) => { setSearchState('Idle'); - setData(JSON.parse(dt) as SCGlobal.DeviceHealthReport[]); + const pagedResults = JSON.parse(dt) as IPagedResult; + setPagedData(pagedResults); + setData(JSON.parse(pagedResults.Data) as SCGlobal.DeviceHealthReport[]) }).fail((d) => setSearchState('Error')); return function cleanup() { if (handle.abort != null) handle.abort(); } - }, [sortKey, ascending, search]); + }, [sortKey, ascending, search, page]); React.useEffect(() => { let handle = getAdditionalFields(); - return () => { if (handle.abort != null) handle.abort(); } @@ -93,14 +104,12 @@ const DeviceHealthReport: Application.Types.iByComponent = (props) => { }, [settingStatus]); - - function getMeters(): JQuery.jqXHR{ + function getMeters(): JQuery.jqXHR { setSearchState('Loading'); let searches = search.map(s => { if (defaultSearchcols.findIndex(item => item.key == s.FieldName) == -1) return { ...s, IsPivotColumn: true }; else return s; }) - return $.ajax({ type: "Post", - url: `${homePath}api/DeviceHealthReport/SearchableList`, + url: `${homePath}api/DeviceHealthReport/PagedList/${page}`, contentType: "application/json; charset=utf-8", dataType: 'json', data: JSON.stringify({ Searches: searches, OrderBy: sortKey, Ascending: ascending }), @@ -157,30 +166,30 @@ const DeviceHealthReport: Application.Types.iByComponent = (props) => {
CollumnList={filterableList} SetFilter={(flds) => setSearch(flds)} Direction={'left'} defaultCollumn={standardSearch} Width={'50%'} Label={'Search'} StorageID="DeviceHealthReportFilter" - ShowLoading={searchState == 'Loading'} ResultNote={searchState == 'Error' ? 'Could not complete Search' : 'Found ' + data.length + ' Meter(s)'} - GetEnum={(setOptions, field) => { - let handle = null; - if (field.type != 'enum' || field.enum == undefined || field.enum.length != 1) - return () => { }; - - handle = $.ajax({ - type: "GET", - url: `${homePath}api/ValueList/Group/${field.enum[0].Value}`, - contentType: "application/json; charset=utf-8", - dataType: 'json', - cache: true, - async: true - }); - - handle.done(d => setOptions(d.map(item => ({ Value: item.Value.toString(), Label: item.Text })))) - return () => { if (handle != null && handle.abort == null) handle.abort(); } - }} + ShowLoading={searchState == 'Loading'} ResultNote={searchState == 'Error' ? 'Could not complete Search' : 'Found ' + data.length + ' Meter(s)'} + GetEnum={(setOptions, field) => { + let handle = null; + if (field.type != 'enum' || field.enum == undefined || field.enum.length != 1) + return () => { }; + + handle = $.ajax({ + type: "GET", + url: `${homePath}api/ValueList/Group/${field.enum[0].Value}`, + contentType: "application/json; charset=utf-8", + dataType: 'json', + cache: true, + async: true + }); + + handle.done(d => setOptions(d.map(item => ({ Value: item.Value.toString(), Label: item.Text })))) + return () => { if (handle != null && handle.abort == null) handle.abort(); } + }} >
  • Connection Status:
    - + { Name="Scada Trigger" Endpoint="ScadaTriggerStatus" /> - -
    + +
  • @@ -227,7 +236,7 @@ const DeviceHealthReport: Application.Types.iByComponent = (props) => { Field={'Name'} HeaderStyle={{ width: 'auto' }} RowStyle={{ width: 'auto' }} - Content={({ item, field }) => {item[field]} } + Content={({ item, field }) => {item[field]}} > Name @@ -261,7 +270,7 @@ const DeviceHealthReport: Application.Types.iByComponent = (props) => { return item.LocationKey }} - + > Substn @@ -270,7 +279,7 @@ const DeviceHealthReport: Application.Types.iByComponent = (props) => { Field={'Model'} HeaderStyle={{ width: '8%' }} RowStyle={{ width: '8%' }} - Content={({ item, field }) => { + Content={({ item, field }) => { const MimdUrl = settings.find(s => s.Name == 'MiMD.Url')?.Value; if (MimdUrl != undefined) @@ -286,7 +295,7 @@ const DeviceHealthReport: Application.Types.iByComponent = (props) => { Field={'TSC'} HeaderStyle={{ width: 50 }} RowStyle={{ width: 50 }} - Content={({ item, field }) => {item[field]} } + Content={({ item, field }) => {item[field]}} > TSC @@ -295,7 +304,7 @@ const DeviceHealthReport: Application.Types.iByComponent = (props) => { Field={'Sector'} HeaderStyle={{ width: '5%' }} RowStyle={{ width: '5%' }} - Content={({ item, field }) => {item[field]} } + Content={({ item, field }) => {item[field]}} > Sector @@ -336,6 +345,39 @@ const DeviceHealthReport: Application.Types.iByComponent = (props) => { Field={'BadDays'} HeaderStyle={{ width: 100 }} RowStyle={{ width: 100, textAlign: 'center' }} + Content={({ item, key, index }) => { + if (item[key] == undefined) + return ''; + return ( + <> +
    setHovered(index)} + onMouseLeave={() => setHovered(null)} + > + {item[key]} +
    + +
      {item['MICBadDays'] == null ? null : +
    • + {`openMIC: ${item['MICBadDays']}`} +
    • + } +
    • + {`miMD: ${item['MiMDBadDays']}`} +
    • +
    • + {`openXDA: ${item['XDABadDays']}`} +
    • +
    +
    + + ) + }} > Bad Days @@ -446,6 +488,12 @@ const DeviceHealthReport: Application.Types.iByComponent = (props) => {
    +
    +
    + + {pagedData != null ? setPage(p - 1)} /> : null} +
    +
    ) } diff --git a/Source/Applications/SystemCenter/wwwroot/Scripts/TSX/SystemCenter/DeviceIssuesPage/OpenMICIssuesPage.tsx b/Source/Applications/SystemCenter/wwwroot/Scripts/TSX/SystemCenter/DeviceIssuesPage/OpenMICIssuesPage.tsx index 64b047a8b..4bdfe519b 100644 --- a/Source/Applications/SystemCenter/wwwroot/Scripts/TSX/SystemCenter/DeviceIssuesPage/OpenMICIssuesPage.tsx +++ b/Source/Applications/SystemCenter/wwwroot/Scripts/TSX/SystemCenter/DeviceIssuesPage/OpenMICIssuesPage.tsx @@ -28,7 +28,6 @@ import { SystemCenter as SC } from '../global'; import { ReactIcons } from '@gpa-gemstone/gpa-symbols'; import _ from 'lodash'; import * as React from 'react'; -import { GenericController } from '@gpa-gemstone/react-interactive'; import { ToolTip } from '@gpa-gemstone/react-forms'; import { ConfigurableTable, ConfigurableColumn, Column } from '@gpa-gemstone/react-table'; import { Plot, Line } from '@gpa-gemstone/react-graph'; @@ -37,7 +36,6 @@ import moment from 'moment'; import { useAppSelector } from '../hooks'; import { SelectRoles } from '../Store/UserSettings'; -const OpenMICDailyStatisticController = new GenericController(`${homePath}api/SystemCenter/Statistics/OpenMIC`, "LastSuccessfulConnection", false); function OpenMICIssuesPage(props: { Meter: OpenXDA.Types.Meter, OpenMICAcronym: string }) { const [data, setData] = React.useState([]); @@ -51,16 +49,26 @@ function OpenMICIssuesPage(props: { Meter: OpenXDA.Types.Meter, OpenMICAcronym: const orderedData = React.useMemo(() => _.orderBy(data, [sortField], [ascending ? 'asc' : 'desc']), [data, sortField, ascending]) + function getStatistics(): JQuery.jqXHR { + return $.ajax({ + type: "Get", + url: `${homePath}api/DeviceHealthReport/OpenMICMeterStatistics/?meter=${props.OpenMICAcronym}`, + contentType: "application/json; charset=utf-8", + dataType: 'json', + cache: false, + async: true + }); + } React.useEffect(() => { setStatus('loading') - const handle = OpenMICDailyStatisticController.PagedSearch([], undefined, undefined, 0, props.Meter.AssetKey).done(result => { - const data = JSON.parse(result.Data as unknown as string); + const handle = getStatistics().done(result => { + const data = JSON.parse(result as unknown as string); setData(data); - }); - setStatus('idle') + setStatus('idle') + }).fail((d) => setStatus('error')); - return () => { + return function cleanup() { if (handle.abort != undefined) handle.abort(); } }, [props.Meter.AssetKey]);