diff --git a/Src/Witsml/Data/Curves/Index.cs b/Src/Witsml/Data/Curves/Index.cs index 09a9cf21d..db0cdcd0e 100644 --- a/Src/Witsml/Data/Curves/Index.cs +++ b/Src/Witsml/Data/Curves/Index.cs @@ -45,7 +45,7 @@ public static Index Start(WitsmlLog log, string customIndexValue = "") return log.IndexType switch { WitsmlLog.WITSML_INDEX_TYPE_MD => new DepthIndex(double.Parse(customIndexValue.IsNumeric() ? customIndexValue : log.StartIndex.Value, CultureInfo.InvariantCulture), - log.StartIndex.Uom), + log.StartIndex?.Uom ?? "m"), WitsmlLog.WITSML_INDEX_TYPE_DATE_TIME => new DateTimeIndex(DateTime.Parse(customIndexValue.NullIfEmpty() ?? log.StartDateTimeIndex, CultureInfo.InvariantCulture)), _ => throw new Exception($"Invalid index type: '{log.IndexType}'") }; @@ -56,7 +56,7 @@ public static Index End(WitsmlLog log, string customIndexValue = "") return log.IndexType switch { WitsmlLog.WITSML_INDEX_TYPE_MD => new DepthIndex(double.Parse(customIndexValue.IsNumeric() ? customIndexValue : log.EndIndex.Value, CultureInfo.InvariantCulture), - log.EndIndex.Uom), + log.EndIndex?.Uom ?? "m"), WitsmlLog.WITSML_INDEX_TYPE_DATE_TIME => new DateTimeIndex(DateTime.Parse(customIndexValue.NullIfEmpty() ?? log.EndDateTimeIndex, CultureInfo.InvariantCulture)), _ => throw new Exception($"Invalid index type: '{log.IndexType}'") }; diff --git a/Src/Witsml/Extensions/UriExtensions.cs b/Src/Witsml/Extensions/UriExtensions.cs index 38053636f..fa59d57ac 100644 --- a/Src/Witsml/Extensions/UriExtensions.cs +++ b/Src/Witsml/Extensions/UriExtensions.cs @@ -14,4 +14,4 @@ public static bool EqualsIgnoreCase(this Uri firstUri, Uri secondUri) { return string.Equals(firstUri?.AbsoluteUri, secondUri?.AbsoluteUri, StringComparison.OrdinalIgnoreCase); } -} \ No newline at end of file +} diff --git a/Src/Witsml/QueryLogger.cs b/Src/Witsml/QueryLogger.cs index c15dd1197..8c6f30342 100644 --- a/Src/Witsml/QueryLogger.cs +++ b/Src/Witsml/QueryLogger.cs @@ -23,7 +23,7 @@ public interface IQueryLogger /// Result code from the witsml server (negative number means error code) /// Result message from the witsml server /// IWitsmlQueryType - void LogQuery(string function, Uri serverUrl, T query, OptionsIn optionsIn, string querySent, bool isSuccessful, + void LogQuery(string function, Uri serverUrl, T query, OptionsIn optionsIn, string querySent, bool isSuccessful, string xmlReceived, short resultCode, string suppMsgOut) where T : IWitsmlQueryType; } @@ -38,7 +38,7 @@ public DefaultQueryLogger() .CreateLogger(); } - public void LogQuery(string function, Uri serverUrl, T query, OptionsIn optionsIn, string querySent, bool isSuccessful, + public void LogQuery(string function, Uri serverUrl, T query, OptionsIn optionsIn, string querySent, bool isSuccessful, string xmlReceived, short resultCode, string suppMsgOut) where T : IWitsmlQueryType { if (xmlReceived != null) diff --git a/Src/Witsml/WitsmlClient.cs b/Src/Witsml/WitsmlClient.cs index bbf5218af..5424a45b4 100644 --- a/Src/Witsml/WitsmlClient.cs +++ b/Src/Witsml/WitsmlClient.cs @@ -125,8 +125,8 @@ private async Task GetFromStoreInnerAsync(T query, OptionsIn optionsIn) wh }; WMLS_GetFromStoreResponse response = await _client.WMLS_GetFromStoreAsync(request); - - LogQueriesSentAndReceived(nameof(_client.WMLS_GetFromStoreAsync), this._serverUrl, query, optionsIn, request.QueryIn, + + LogQueriesSentAndReceived(nameof(_client.WMLS_GetFromStoreAsync), this._serverUrl, query, optionsIn, request.QueryIn, response.IsSuccessful(), response.XMLout, response.Result, response.SuppMsgOut); if (response.IsSuccessful()) @@ -157,8 +157,8 @@ private async Task GetFromStoreInnerAsync(T query, OptionsIn optionsIn) wh }; WMLS_GetFromStoreResponse response = await _client.WMLS_GetFromStoreAsync(request); - - LogQueriesSentAndReceived(nameof(_client.WMLS_GetFromStoreAsync), this._serverUrl, query, optionsIn, + + LogQueriesSentAndReceived(nameof(_client.WMLS_GetFromStoreAsync), this._serverUrl, query, optionsIn, request.QueryIn, response.IsSuccessful(), response.XMLout, response.Result, response.SuppMsgOut); if (response.IsSuccessful()) @@ -216,7 +216,7 @@ public async Task AddToStoreAsync(T query) where T : IWitsmlQuer WMLS_AddToStoreResponse response = await _client.WMLS_AddToStoreAsync(request); - LogQueriesSentAndReceived(nameof(_client.WMLS_AddToStoreAsync), this._serverUrl, query, optionsIn, + LogQueriesSentAndReceived(nameof(_client.WMLS_AddToStoreAsync), this._serverUrl, query, optionsIn, request.XMLin, response.IsSuccessful(), null, response.Result, response.SuppMsgOut); if (response.IsSuccessful()) @@ -248,7 +248,7 @@ public async Task UpdateInStoreAsync(T query) where T : IWitsmlQ WMLS_UpdateInStoreResponse response = await _client.WMLS_UpdateInStoreAsync(request); - LogQueriesSentAndReceived(nameof(_client.WMLS_UpdateInStoreAsync), this._serverUrl, query, null, + LogQueriesSentAndReceived(nameof(_client.WMLS_UpdateInStoreAsync), this._serverUrl, query, null, request.XMLin, response.IsSuccessful(), null, response.Result, response.SuppMsgOut); if (response.IsSuccessful()) @@ -280,7 +280,7 @@ public async Task DeleteFromStoreAsync(T query) where T : IWitsm WMLS_DeleteFromStoreResponse response = await _client.WMLS_DeleteFromStoreAsync(request); - LogQueriesSentAndReceived(nameof(_client.WMLS_DeleteFromStoreAsync), this._serverUrl, query, null, + LogQueriesSentAndReceived(nameof(_client.WMLS_DeleteFromStoreAsync), this._serverUrl, query, null, request.QueryIn, response.IsSuccessful(), null, response.Result, response.SuppMsgOut); if (response.IsSuccessful()) @@ -314,7 +314,7 @@ public async Task TestConnectionAsync() return new QueryResult(true); } - private void LogQueriesSentAndReceived(string function, Uri serverUrl, T query, OptionsIn optionsIn, + private void LogQueriesSentAndReceived(string function, Uri serverUrl, T query, OptionsIn optionsIn, string querySent, bool isSuccessful, string xmlReceived, short resultCode, string suppMsgOut = null) where T : IWitsmlQueryType { _queryLogger?.LogQuery(function, serverUrl, query, optionsIn, querySent, isSuccessful, xmlReceived, resultCode, suppMsgOut); diff --git a/Src/WitsmlExplorer.Api/HttpHandlers/JobHandler.cs b/Src/WitsmlExplorer.Api/HttpHandlers/JobHandler.cs index 6adc40043..0ca518a7e 100644 --- a/Src/WitsmlExplorer.Api/HttpHandlers/JobHandler.cs +++ b/Src/WitsmlExplorer.Api/HttpHandlers/JobHandler.cs @@ -1,4 +1,6 @@ using System.Collections.Generic; +using System.IdentityModel.Tokens.Jwt; +using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; @@ -6,6 +8,7 @@ using Microsoft.Extensions.Configuration; using WitsmlExplorer.Api.Configuration; +using WitsmlExplorer.Api.Extensions; using WitsmlExplorer.Api.Jobs; using WitsmlExplorer.Api.Middleware; using WitsmlExplorer.Api.Models; @@ -50,6 +53,32 @@ public static IResult GetUserJobInfos(IJobCache jobCache, HttpRequest httpReques return TypedResults.Ok(jobCache.GetJobInfosByUser(userName)); } + [Produces(typeof(IEnumerable))] + public static IResult GetUserJobInfo(string jobId, IJobCache jobCache, HttpRequest httpRequest, IConfiguration configuration, ICredentialsService credentialsService) + { + EssentialHeaders eh = new(httpRequest); + bool useOAuth2 = StringHelpers.ToBoolean(configuration[ConfigConstants.OAuth2Enabled]); + string userName = useOAuth2 ? credentialsService.GetClaimFromToken(eh.GetBearerToken(), "upn") : eh.TargetUsername; + if (!useOAuth2) + { + credentialsService.VerifyUserIsLoggedIn(eh, ServerType.Target); + } + JobInfo job = jobCache.GetJobInfoById(jobId); + if (job.Username != userName && (!useOAuth2 || !IsAdminOrDeveloper(eh.GetBearerToken()))) + { + return TypedResults.Forbid(); + } + return TypedResults.Ok(job); + } + + private static bool IsAdminOrDeveloper(string token) + { + JwtSecurityTokenHandler handler = new(); + JwtSecurityToken jwt = handler.ReadJwtToken(token); + IEnumerable userRoles = jwt.Claims.Where(n => n.Type == "roles").Select(n => n.Value); + return userRoles.Contains(AuthorizationPolicyRoles.ADMIN) || userRoles.Contains(AuthorizationPolicyRoles.DEVELOPER); + } + [Produces(typeof(IEnumerable))] public static IResult GetAllJobInfos(IJobCache jobCache, IConfiguration configuration) { diff --git a/Src/WitsmlExplorer.Api/Jobs/CheckLogHeaderJob.cs b/Src/WitsmlExplorer.Api/Jobs/CheckLogHeaderJob.cs new file mode 100644 index 000000000..2a557bb0f --- /dev/null +++ b/Src/WitsmlExplorer.Api/Jobs/CheckLogHeaderJob.cs @@ -0,0 +1,29 @@ +using WitsmlExplorer.Api.Models; + +namespace WitsmlExplorer.Api.Jobs +{ + public record CheckLogHeaderJob : Job + { + public LogObject LogReference { get; init; } + + public override string Description() + { + return $"Check Log Headers - Log: {LogReference.Name}"; + } + + public override string GetObjectName() + { + return LogReference.Name; + } + + public override string GetWellboreName() + { + return LogReference.WellboreName; + } + + public override string GetWellName() + { + return LogReference.WellName; + } + } +} diff --git a/Src/WitsmlExplorer.Api/Jobs/JobInfo.cs b/Src/WitsmlExplorer.Api/Jobs/JobInfo.cs index 481ad3370..d9f05e338 100644 --- a/Src/WitsmlExplorer.Api/Jobs/JobInfo.cs +++ b/Src/WitsmlExplorer.Api/Jobs/JobInfo.cs @@ -1,6 +1,8 @@ using System; using System.Text.Json.Serialization; +using WitsmlExplorer.Api.Models.Reports; + namespace WitsmlExplorer.Api.Jobs { public record JobInfo @@ -42,6 +44,8 @@ public JobInfo() public string FailedReason { get; set; } + public BaseReport Report { get; set; } + private JobStatus _status; [JsonConverter(typeof(JsonStringEnumConverter))] diff --git a/Src/WitsmlExplorer.Api/Models/JobType.cs b/Src/WitsmlExplorer.Api/Models/JobType.cs index 466bfce0d..82e2f2f29 100644 --- a/Src/WitsmlExplorer.Api/Models/JobType.cs +++ b/Src/WitsmlExplorer.Api/Models/JobType.cs @@ -42,6 +42,7 @@ public enum JobType BatchModifyWell, ImportLogData, ReplaceComponents, - ReplaceObjects + ReplaceObjects, + CheckLogHeader } } diff --git a/Src/WitsmlExplorer.Api/Models/Reports/BaseReport.cs b/Src/WitsmlExplorer.Api/Models/Reports/BaseReport.cs new file mode 100644 index 000000000..78b64fa1a --- /dev/null +++ b/Src/WitsmlExplorer.Api/Models/Reports/BaseReport.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; + +namespace WitsmlExplorer.Api.Models.Reports +{ + public class BaseReport + { + public string Title { get; set; } + public string Summary { get; init; } + public IEnumerable ReportItems { get; init; } + } +} diff --git a/Src/WitsmlExplorer.Api/Models/Reports/CheckLogHeaderReport.cs b/Src/WitsmlExplorer.Api/Models/Reports/CheckLogHeaderReport.cs new file mode 100644 index 000000000..bd94851de --- /dev/null +++ b/Src/WitsmlExplorer.Api/Models/Reports/CheckLogHeaderReport.cs @@ -0,0 +1,16 @@ +namespace WitsmlExplorer.Api.Models.Reports +{ + public class CheckLogHeaderReport : BaseReport + { + public LogObject LogReference { get; init; } + } + + public class CheckLogHeaderReportItem + { + public string Mnemonic { get; init; } + public string HeaderStartIndex { get; init; } + public string DataStartIndex { get; init; } + public string HeaderEndIndex { get; init; } + public string DataEndIndex { get; init; } + } +} diff --git a/Src/WitsmlExplorer.Api/Query/LogQueries.cs b/Src/WitsmlExplorer.Api/Query/LogQueries.cs index 4202e78f6..1865b65c9 100644 --- a/Src/WitsmlExplorer.Api/Query/LogQueries.cs +++ b/Src/WitsmlExplorer.Api/Query/LogQueries.cs @@ -74,12 +74,12 @@ public static WitsmlLogs GetLogContent( switch (indexType) { case WitsmlLog.WITSML_INDEX_TYPE_MD: - queryLog.StartIndex = new WitsmlIndex((DepthIndex)startIndex); - queryLog.EndIndex = new WitsmlIndex((DepthIndex)endIndex); + queryLog.StartIndex = startIndex != null ? new WitsmlIndex((DepthIndex)startIndex) : new WitsmlIndex(); + queryLog.EndIndex = endIndex != null ? new WitsmlIndex((DepthIndex)endIndex) : new WitsmlIndex(); break; case WitsmlLog.WITSML_INDEX_TYPE_DATE_TIME: - queryLog.StartDateTimeIndex = startIndex.GetValueAsString(); - queryLog.EndDateTimeIndex = endIndex.GetValueAsString(); + queryLog.StartDateTimeIndex = startIndex?.GetValueAsString() ?? ""; + queryLog.EndDateTimeIndex = endIndex?.GetValueAsString() ?? ""; break; default: break; @@ -148,5 +148,31 @@ public static WitsmlLogs DeleteMnemonics(string wellUid, string wellboreUid, str }.AsSingletonList() }; } + + public static WitsmlLogs GetLogHeaderIndexes(string wellUid, string wellboreUid, string logUid) + { + return new WitsmlLogs + { + Logs = new WitsmlLog + { + UidWell = wellUid, + UidWellbore = wellboreUid, + Uid = logUid, + StartIndex = new WitsmlIndex(), + EndIndex = new WitsmlIndex(), + StartDateTimeIndex = "", + EndDateTimeIndex = "", + IndexCurve = new WitsmlIndexCurve(), + LogCurveInfo = new WitsmlLogCurveInfo + { + Mnemonic = "", + MinIndex = new WitsmlIndex(), + MaxIndex = new WitsmlIndex(), + MinDateTimeIndex = "", + MaxDateTimeIndex = "" + }.AsSingletonList(), + }.AsSingletonList() + }; + } } } diff --git a/Src/WitsmlExplorer.Api/Query/TrajectoryQueries.cs b/Src/WitsmlExplorer.Api/Query/TrajectoryQueries.cs index dfb83295a..b78ced85f 100644 --- a/Src/WitsmlExplorer.Api/Query/TrajectoryQueries.cs +++ b/Src/WitsmlExplorer.Api/Query/TrajectoryQueries.cs @@ -90,7 +90,7 @@ public static WitsmlTrajectories DeleteTrajectoryStations(string wellUid, string }.AsSingletonList() }; } - + /// /// Create trajectories witsml model. /// @@ -117,7 +117,7 @@ public static WitsmlTrajectories CreateTrajectory(Trajectory trajectory) }.AsSingletonList() }; } - + public static WitsmlTrajectories UpdateTrajectoryStation(TrajectoryStation trajectoryStation, ObjectReference trajectoryReference) { WitsmlTrajectoryStation ts = new() diff --git a/Src/WitsmlExplorer.Api/Routes.cs b/Src/WitsmlExplorer.Api/Routes.cs index 3a319de15..861bf3846 100644 --- a/Src/WitsmlExplorer.Api/Routes.cs +++ b/Src/WitsmlExplorer.Api/Routes.cs @@ -80,6 +80,7 @@ public static void ConfigureApi(this WebApplication app, IConfiguration configur app.MapPost("/jobs/{jobType}", JobHandler.CreateJob, useOAuth2); app.MapGet("/jobs/userjobinfos", JobHandler.GetUserJobInfos, useOAuth2); + app.MapGet("/jobs/userjobinfo/{jobId}", JobHandler.GetUserJobInfo, useOAuth2); app.MapGet("/jobs/alljobinfos", JobHandler.GetAllJobInfos, useOAuth2, AuthorizationPolicyRoles.ADMINORDEVELOPER); app.MapGet("/credentials/authorize", AuthorizeHandler.Authorize, useOAuth2); diff --git a/Src/WitsmlExplorer.Api/Services/JobCache.cs b/Src/WitsmlExplorer.Api/Services/JobCache.cs index ec63faa49..69862b538 100644 --- a/Src/WitsmlExplorer.Api/Services/JobCache.cs +++ b/Src/WitsmlExplorer.Api/Services/JobCache.cs @@ -14,6 +14,7 @@ public interface IJobCache { void CacheJob(JobInfo jobInfo); IEnumerable GetJobInfosByUser(string username); + JobInfo GetJobInfoById(string jobId); IEnumerable GetAllJobInfos(); } @@ -51,6 +52,11 @@ public IEnumerable GetJobInfosByUser(string username) return _jobs.Values.Where(job => job.Username == username); } + public JobInfo GetJobInfoById(string jobId) + { + return _jobs[jobId]; + } + public IEnumerable GetAllJobInfos() { return _jobs.Values; @@ -79,6 +85,5 @@ private void Cleanup() } _logger.LogInformation("JobCache cleanup finished, deleted: {deleted}, failed: {failed}, remaining: {remaining}", deleted, failed, _jobs.Count); } - } } diff --git a/Src/WitsmlExplorer.Api/Workers/BaseWorker.cs b/Src/WitsmlExplorer.Api/Workers/BaseWorker.cs index c8539bbc3..8f5d55fc1 100644 --- a/Src/WitsmlExplorer.Api/Workers/BaseWorker.cs +++ b/Src/WitsmlExplorer.Api/Workers/BaseWorker.cs @@ -64,7 +64,7 @@ protected IWitsmlClient GetSourceWitsmlClientOrThrow() job.JobInfo.Status = JobStatus.Failed; job.JobInfo.FailedReason = ex.Message; Logger.LogError("An unexpected exception has occured: {ex}", ex); - return (new WorkerResult(new Uri(job.JobInfo.TargetServer), false, $"{job.JobInfo.JobType} failed", ex.Message), null); + return (new WorkerResult(new Uri(job.JobInfo.TargetServer), false, $"{job.JobInfo.JobType} failed", ex.Message, jobId: job.JobInfo.Id), null); } } diff --git a/Src/WitsmlExplorer.Api/Workers/CheckLogHeaderWorker.cs b/Src/WitsmlExplorer.Api/Workers/CheckLogHeaderWorker.cs new file mode 100644 index 000000000..0b8865f7a --- /dev/null +++ b/Src/WitsmlExplorer.Api/Workers/CheckLogHeaderWorker.cs @@ -0,0 +1,217 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +using Microsoft.Extensions.Logging; + +using Witsml.Data; +using Witsml.ServiceReference; + +using WitsmlExplorer.Api.Jobs; +using WitsmlExplorer.Api.Models; +using WitsmlExplorer.Api.Models.Reports; +using WitsmlExplorer.Api.Query; +using WitsmlExplorer.Api.Services; + +namespace WitsmlExplorer.Api.Workers +{ + public class CheckLogHeaderWorker : BaseWorker, IWorker + { + public JobType JobType => JobType.CheckLogHeader; + + public CheckLogHeaderWorker(ILogger logger, IWitsmlClientProvider witsmlClientProvider) : base(witsmlClientProvider, logger) { } + public override async Task<(WorkerResult, RefreshAction)> Execute(CheckLogHeaderJob job) + { + string wellUid = job.LogReference.WellUid; + string wellboreUid = job.LogReference.WellboreUid; + string logUid = job.LogReference.Uid; + string indexType = job.LogReference.IndexType; + string jobId = job.JobInfo.Id; + bool isDepthLog = indexType == WitsmlLog.WITSML_INDEX_TYPE_MD; + string indexCurve; + // Dictionaries that maps from a mnemonic to the mnemonics start or end index + Dictionary headerStartValues; + Dictionary headerEndValues; + Dictionary dataStartValues; + Dictionary dataEndValues; + // These indexes are used to check the global start and end indexes for a log (not the ones found in logCurveInfo) + string headerStartIndex; + string headerEndIndex; + string dataStartIndex; + string dataEndIndex; + + // Get the header indexes + (Dictionary, Dictionary, string, string, string)? headerResult = await GetHeaderValues(wellUid, wellboreUid, logUid, isDepthLog); + if (headerResult == null) + { + string reason = $"Did not find witsml log for wellUid: {wellUid}, wellboreUid: {wellboreUid}, logUid: {logUid}"; + return (new WorkerResult(GetTargetWitsmlClientOrThrow().GetServerHostname(), false, "Unable to find log", reason, jobId: jobId), null); + } + (headerStartValues, headerEndValues, headerStartIndex, headerEndIndex, indexCurve) = headerResult.Value; + + // Get the data indexes + (Dictionary, Dictionary, string, string)? dataResult = await GetDataValues(wellUid, wellboreUid, logUid, indexType, indexCurve); + if (dataResult == null) + { + string reason = $"The log with wellUid: {wellUid}, wellboreUid: {wellboreUid}, logUid: {logUid} does not contain any data"; + return (new WorkerResult(GetTargetWitsmlClientOrThrow().GetServerHostname(), false, "No log data", reason, jobId: jobId), null); + } + (dataStartValues, dataEndValues, dataStartIndex, dataEndIndex) = dataResult.Value; + + List mismatchingIndexes = GetMismatchingIndexes(headerStartValues, headerEndValues, dataStartValues, dataEndValues, headerStartIndex, headerEndIndex, dataStartIndex, dataEndIndex, isDepthLog); + + CheckLogHeaderReport report = GetReport(mismatchingIndexes, job.LogReference, isDepthLog); + job.JobInfo.Report = report; + + Logger.LogInformation("{JobType} - Job successful", GetType().Name); + + WorkerResult workerResult = new(GetTargetWitsmlClientOrThrow().GetServerHostname(), true, $"Checked header consistency for log: {logUid}", jobId: jobId); + return (workerResult, null); + } + + public async Task<(Dictionary, Dictionary, string, string, string)?> GetHeaderValues(string wellUid, string wellboreUid, string logUid, bool isDepthLog) + { + WitsmlLogs headerQuery = LogQueries.GetLogHeaderIndexes(wellUid, wellboreUid, logUid); + WitsmlLogs headerResult = await GetTargetWitsmlClientOrThrow().GetFromStoreNullableAsync(headerQuery, new OptionsIn(ReturnElements.Requested)); + if (headerResult == null) + { + return null; + } + WitsmlLog headerResultLog = (WitsmlLog)headerResult.Objects.First(); + string headerEndIndex = isDepthLog ? headerResultLog.EndIndex.Value : headerResultLog.EndDateTimeIndex; + string headerStartIndex = isDepthLog ? headerResultLog.StartIndex.Value : headerResultLog.StartDateTimeIndex; + Dictionary headerStartValues = headerResultLog.LogCurveInfo.ToDictionary(l => l.Mnemonic, l => (isDepthLog ? l.MinIndex?.Value : l.MinDateTimeIndex) ?? ""); + Dictionary headerEndValues = headerResultLog.LogCurveInfo.ToDictionary(l => l.Mnemonic, l => (isDepthLog ? l.MaxIndex?.Value : l.MaxDateTimeIndex) ?? ""); + return (headerStartValues, headerEndValues, headerStartIndex, headerEndIndex, headerResultLog.IndexCurve.Value); + } + + public async Task<(Dictionary, Dictionary, string, string)?> GetDataValues(string wellUid, string wellboreUid, string logUid, string indexType, string indexCurve) + { + WitsmlLogs dataQuery = LogQueries.GetLogContent(wellUid, wellboreUid, logUid, indexType, Enumerable.Empty(), null, null); + WitsmlLogs dataStartResult = await GetTargetWitsmlClientOrThrow().GetFromStoreNullableAsync(dataQuery, new OptionsIn(ReturnElements.DataOnly, MaxReturnNodes: 1)); + WitsmlLogs dataEndResult = await GetTargetWitsmlClientOrThrow().GetFromStoreNullableAsync(dataQuery, new OptionsIn(ReturnElements.DataOnly, RequestLatestValues: 1)); + WitsmlLog dataStartResultLog = (WitsmlLog)dataStartResult.Objects.First(); + WitsmlLog dataEndResultLog = (WitsmlLog)dataEndResult.Objects.First(); + if (dataStartResultLog.LogData == null || dataEndResultLog.LogData == null) + { + return null; + } + IEnumerable> endResultLogData = dataEndResultLog.LogData.Data.Select(data => data.Data.Split(",")); + string[] startResultLogData = dataStartResultLog.LogData.Data.First().Data.Split(","); + IEnumerable dataStartIndexes = startResultLogData.Select(data => data == "" ? "" : startResultLogData[0]); + IEnumerable dataEndIndexes = ExtractColumnIndexes(endResultLogData); + string[] startMnemonics = dataStartResultLog.LogData.MnemonicList.Split(","); + string[] endMnemonics = dataEndResultLog.LogData.MnemonicList.Split(","); + Dictionary dataStartValues = dataStartIndexes.Select((value, index) => new { mnemonic = startMnemonics[index], value }).ToDictionary(d => d.mnemonic, d => d.value); + Dictionary dataEndValues = dataEndIndexes.Where(value => !string.IsNullOrEmpty(value)).Select((value, index) => new { mnemonic = endMnemonics[index], value }).ToDictionary(d => d.mnemonic, d => d.value); + + // Only the first data row is fetched for the start indexes. The mnemonics that don't have a value at the start index need to fetch their start index individually. + dataStartValues = await AddStartIndexForMissingMnemonics(wellUid, wellboreUid, logUid, dataStartValues, startMnemonics, endMnemonics, indexCurve); + + return (dataStartValues, dataEndValues, dataStartIndexes.First(), dataEndIndexes.First()); + } + + private async Task> AddStartIndexForMissingMnemonics(string wellUid, string wellboreUid, string logUid, Dictionary dataStartValues, string[] startMnemonics, string[] endMnemonics, string indexCurve) + { + string[] missingMnemonics = endMnemonics.Where(mnemonic => !startMnemonics.Contains(mnemonic)) + .Concat(dataStartValues.Where((entry) => entry.Value == "").Select((entry) => entry.Key)).Distinct().ToArray(); + if (missingMnemonics.Any()) + { + IEnumerable missingIndexQueries = missingMnemonics.Select(mnemonic => LogQueries.GetLogContent(wellUid, wellboreUid, logUid, null, new List() { indexCurve, mnemonic }, null, null)); + // Request a data row for each mnemonic to get the start indexes of that mnemonic + List> missingDataResults = missingIndexQueries.Select(query => GetTargetWitsmlClientOrThrow().GetFromStoreNullableAsync(query, new OptionsIn(ReturnElements.DataOnly, MaxReturnNodes: 1))).ToList(); + await Task.WhenAll(missingDataResults); + IEnumerable missingLogs = missingDataResults.Select(r => (WitsmlLog)r.Result.Objects.First()); + IEnumerable missingDataIndexes = missingLogs.Select(l => l.LogData.Data?.FirstOrDefault()?.Data?.Split(",")?[0] ?? ""); + // Insert the indexes from the missing mnemonics to the original dict. + missingDataIndexes + .Select((value, index) => new { mnemonic = missingMnemonics[index], value }) + .ToList() + .ForEach(item => dataStartValues[item.mnemonic] = item.value); + } + return dataStartValues; + } + + public static List GetMismatchingIndexes(Dictionary headerStartValues, Dictionary headerEndValues, Dictionary dataStartValues, Dictionary dataEndValues, string headerStartIndex, string headerEndIndex, string dataStartIndex, string dataEndIndex, bool isDepthLog) + { + List mismatchingIndexes = new(); + // Check the header indexes + if (HasMismatch(isDepthLog, headerStartIndex, dataStartIndex, headerEndIndex, dataEndIndex)) + { + mismatchingIndexes.Add(new CheckLogHeaderReportItem() + { + Mnemonic = "Log Header", + HeaderStartIndex = headerStartIndex, + HeaderEndIndex = headerEndIndex, + DataStartIndex = dataStartIndex, + DataEndIndex = dataEndIndex, + }); + } + + // Check the header logCurveInfo indexes + foreach (string mnemonic in dataStartValues.Keys) + { + if (HasMismatch(isDepthLog, headerStartValues[mnemonic], dataStartValues[mnemonic], headerEndValues[mnemonic], dataEndValues[mnemonic])) + { + mismatchingIndexes.Add(new CheckLogHeaderReportItem() + { + Mnemonic = mnemonic, + HeaderStartIndex = headerStartValues[mnemonic], + HeaderEndIndex = headerEndValues[mnemonic], + DataStartIndex = dataStartValues[mnemonic], + DataEndIndex = dataEndValues[mnemonic], + }); + } + } + + return mismatchingIndexes; + } + + private static bool HasMismatch(bool isDepthLog, string startIndex1, string startIndex2, string endIndex1, string endIndex2) + { + if (isDepthLog || string.IsNullOrEmpty(startIndex1) || string.IsNullOrEmpty(endIndex1) || string.IsNullOrEmpty(startIndex2) || string.IsNullOrEmpty(endIndex2)) + { + return startIndex1 != startIndex2 || endIndex1 != endIndex2; + } + else + { + return DateTime.Parse(startIndex1) != DateTime.Parse(startIndex2) || DateTime.Parse(endIndex1) != DateTime.Parse(endIndex2); + } + } + + private static CheckLogHeaderReport GetReport(List mismatchingIndexes, LogObject logReference, bool isDepthLog) + { + return new CheckLogHeaderReport + { + Title = $"Check Log Header Index Report - {logReference.Name}", + Summary = mismatchingIndexes.Count > 0 + ? $"Found {mismatchingIndexes.Count} header index mismatches for {(isDepthLog ? "depth" : "time")} log '{logReference.Name}':" + : "No mismatches were found in the header indexes.", + LogReference = logReference, + ReportItems = mismatchingIndexes + }; + } + + private static IEnumerable ExtractColumnIndexes(IEnumerable> data, int indexColumn = 0) + { + List result = Enumerable.Repeat(string.Empty, data.First().Count()).ToList(); + List> list = data.ToList(); + + int rows = list.Count; + + for (int row = 0; row < rows; row++) + { + List rowList = list[row].ToList(); + for (int col = 0; col < rowList.Count; col++) + { + if (!string.IsNullOrEmpty(rowList[col])) + { + result[col] = rowList[indexColumn]; + } + } + } + return result; + } + } +} diff --git a/Src/WitsmlExplorer.Api/Workers/WorkerResult.cs b/Src/WitsmlExplorer.Api/Workers/WorkerResult.cs index ed8871e24..46c23fd74 100644 --- a/Src/WitsmlExplorer.Api/Workers/WorkerResult.cs +++ b/Src/WitsmlExplorer.Api/Workers/WorkerResult.cs @@ -4,13 +4,14 @@ namespace WitsmlExplorer.Api.Workers { public class WorkerResult { - public WorkerResult(Uri serverUrl, bool isSuccess, string message, string reason = null, EntityDescription description = null) + public WorkerResult(Uri serverUrl, bool isSuccess, string message, string reason = null, EntityDescription description = null, string jobId = null) { ServerUrl = serverUrl; IsSuccess = isSuccess; Message = message; Reason = reason; Description = description; + JobId = jobId; } public Uri ServerUrl { get; } @@ -18,6 +19,7 @@ public WorkerResult(Uri serverUrl, bool isSuccess, string message, string reason public string Message { get; } public string Reason { get; } public EntityDescription Description { get; } + public string JobId { get; } } public class EntityDescription diff --git a/Src/WitsmlExplorer.Frontend/__testUtils__/testUtils.tsx b/Src/WitsmlExplorer.Frontend/__testUtils__/testUtils.tsx index 224c01555..54f3d7ca9 100644 --- a/Src/WitsmlExplorer.Frontend/__testUtils__/testUtils.tsx +++ b/Src/WitsmlExplorer.Frontend/__testUtils__/testUtils.tsx @@ -13,6 +13,7 @@ import ChangeLog from "../models/changeLog"; import CommonData from "../models/commonData"; import FluidsReport from "../models/fluidsReport"; import FormationMarker from "../models/formationMarker"; +import JobInfo from "../models/jobs/jobInfo"; import LogCurveInfo from "../models/logCurveInfo"; import LogObject from "../models/logObject"; import Measure from "../models/measure"; @@ -31,6 +32,7 @@ import Tubular from "../models/tubular"; import WbGeometryObject from "../models/wbGeometry"; import Well, { emptyWell } from "../models/well"; import Wellbore, { emptyWellbore } from "../models/wellbore"; +import { Notification } from "../services/notificationService"; import { light } from "../styles/Colors"; import { getTheme } from "../styles/material-eds"; @@ -111,6 +113,38 @@ export function getWellbore(overrides?: Partial): Wellbore { }; } +export function getJobInfo(overrides?: Partial): JobInfo { + return { + jobType: "", + description: "", + id: "", + username: "", + witsmlTargetUsername: "", + witsmlSourceUsername: "", + sourceServer: "", + targetServer: "", + wellName: "", + wellboreName: "", + objectName: "", + startTime: "", + endTime: "", + killTime: "", + status: "", + failedReason: "", + report: null, + ...overrides + }; +} + +export function getNotification(overrides?: Partial): Notification { + return { + serverUrl: new URL("http://example.com"), + isSuccess: true, + message: "", + ...overrides + }; +} + export function getObjectOnWellbore(overrides?: Partial): ObjectOnWellbore { return { uid: "uid", diff --git a/Src/WitsmlExplorer.Frontend/components/ContentViews/CurveValuesView.tsx b/Src/WitsmlExplorer.Frontend/components/ContentViews/CurveValuesView.tsx index a36b4145b..bd8bdce31 100644 --- a/Src/WitsmlExplorer.Frontend/components/ContentViews/CurveValuesView.tsx +++ b/Src/WitsmlExplorer.Frontend/components/ContentViews/CurveValuesView.tsx @@ -30,14 +30,7 @@ export const CurveValuesView = (): React.ReactElement => { const [isLoading, setIsLoading] = useState(true); const [selectedRows, setSelectedRows] = useState([]); const selectedLog = selectedObject as LogObject; - const { exportData, properties: exportOptions } = useExport({ - fileExtension: ".csv", - newLineCharacter: "\n", - outputMimeType: "text/csv", - separator: ",", - omitSpecialCharactersFromFilename: true, - appendDateTime: true - }); + const { exportData, exportOptions } = useExport(); const getDeleteLogCurveValuesJob = (currentSelected: LogCurveInfoRow[], checkedContentItems: ContentTableRow[], selectedLog: LogObject) => { const indexRanges = getIndexRanges(checkedContentItems, selectedLog); diff --git a/Src/WitsmlExplorer.Frontend/components/ContentViews/JobsView.tsx b/Src/WitsmlExplorer.Frontend/components/ContentViews/JobsView.tsx index 549261e69..d0ecf49da 100644 --- a/Src/WitsmlExplorer.Frontend/components/ContentViews/JobsView.tsx +++ b/Src/WitsmlExplorer.Frontend/components/ContentViews/JobsView.tsx @@ -5,6 +5,7 @@ import NavigationContext from "../../contexts/navigationContext"; import OperationContext from "../../contexts/operationContext"; import OperationType from "../../contexts/operationType"; import JobInfo from "../../models/jobs/jobInfo"; +import BaseReport from "../../models/reports/BaseReport"; import { Server } from "../../models/server"; import { adminRole, developerRole, getUserAppRoles, msalEnabled } from "../../msal/MsalAuthProvider"; import JobService from "../../services/jobService"; @@ -13,6 +14,7 @@ import { Colors } from "../../styles/Colors"; import { getContextMenuPosition } from "../ContextMenus/ContextMenu"; import JobInfoContextMenu, { JobInfoContextMenuProps } from "../ContextMenus/JobInfoContextMenu"; import formatDateString from "../DateFormatter"; +import { ReportModal } from "../Modals/ReportModal"; import { clipLongString } from "./ViewUtils"; import { ContentTable, ContentTableColumn, ContentType } from "./table"; @@ -80,6 +82,11 @@ export const JobsView = (): React.ReactElement => { dispatchOperation({ type: OperationType.DisplayContextMenu, payload: { component: , position } }); }; + const onClickReport = (report: BaseReport) => { + const reportModalProps = { report }; + dispatchOperation({ type: OperationType.DisplayModal, payload: }); + }; + const columns: ContentTableColumn[] = [ { property: "startTime", label: "Start time", type: ContentType.DateTime }, { property: "jobType", label: "Job Type", type: ContentType.String }, @@ -87,6 +94,7 @@ export const JobsView = (): React.ReactElement => { { property: "wellboreName", label: "Wellbore Name", type: ContentType.String }, { property: "objectName", label: "Object Name(s)", type: ContentType.String }, { property: "status", label: "Status", type: ContentType.String }, + { property: "report", label: "Report", type: ContentType.Component }, { property: "failedReason", label: "Failure Reason", type: ContentType.String }, { property: "targetServer", label: "Target Server", type: ContentType.String }, { property: "sourceServer", label: "Source Server", type: ContentType.String }, @@ -108,6 +116,7 @@ export const JobsView = (): React.ReactElement => { endTime: formatDateString(jobInfo.endTime, timeZone), targetServer: serverUrlToName(servers, jobInfo.targetServer), sourceServer: serverUrlToName(servers, jobInfo.sourceServer), + report: jobInfo.report ? onClickReport(jobInfo.report)}>Report : null, jobInfo: jobInfo }; }) @@ -167,5 +176,9 @@ const StyledSwitch = styled(Switch)<{ colors: Colors }>` margin-left: 0; } `; +const ReportButton = styled.div` + text-decoration: underline; + cursor: pointer; +`; export default JobsView; diff --git a/Src/WitsmlExplorer.Frontend/components/ContentViews/table/ColumnDef.tsx b/Src/WitsmlExplorer.Frontend/components/ContentViews/table/ColumnDef.tsx index 4a338a5a5..460abb3bd 100644 --- a/Src/WitsmlExplorer.Frontend/components/ContentViews/table/ColumnDef.tsx +++ b/Src/WitsmlExplorer.Frontend/components/ContentViews/table/ColumnDef.tsx @@ -3,13 +3,14 @@ import { ColumnDef, Row, SortingFn, Table } from "@tanstack/react-table"; import { useMemo } from "react"; import Icon from "../../../styles/Icons"; import { getFromStorage, orderingStorageKey, widthsStorageKey } from "./contentTableStorage"; -import { activeId, calculateColumnWidth, expanderId, measureSortingFn, selectId, toggleRow } from "./contentTableUtils"; +import { activeId, calculateColumnWidth, componentSortingFn, expanderId, measureSortingFn, selectId, toggleRow } from "./contentTableUtils"; import { ContentTableColumn, ContentType } from "./tableParts"; declare module "@tanstack/react-table" { // eslint-disable-next-line @typescript-eslint/no-unused-vars interface SortingFns { [measureSortingFn]: SortingFn; + [componentSortingFn]: SortingFn; } } @@ -26,6 +27,7 @@ export const useColumnDef = (viewId: string, columns: ContentTableColumn[], inse size: savedWidths ? savedWidths[column.label] : calculateColumnWidth(column.label, isCompactMode, column.type), meta: { type: column.type }, sortingFn: getSortingFn(column.type), + ...addComponentCell(column.type), ...addActiveCurveFiltering(column.label) }; }); @@ -61,6 +63,15 @@ const addActiveCurveFiltering = (columnLabel: string): Partial> => { + return columnType == ContentType.Component + ? { + cell: (props) => props.getValue(), + sortingFn: componentSortingFn + } + : {}; +}; + const getExpanderColumnDef = (isCompactMode: boolean): ColumnDef => { return { id: expanderId, diff --git a/Src/WitsmlExplorer.Frontend/components/ContentViews/table/ContentTable.tsx b/Src/WitsmlExplorer.Frontend/components/ContentViews/table/ContentTable.tsx index 6dbe670f3..e3e915ea5 100644 --- a/Src/WitsmlExplorer.Frontend/components/ContentViews/table/ContentTable.tsx +++ b/Src/WitsmlExplorer.Frontend/components/ContentViews/table/ContentTable.tsx @@ -27,6 +27,7 @@ import { StyledResizer, StyledTable, StyledTd, StyledTh, StyledTr, TableContaine import { calculateHorizontalSpace, calculateRowHeight, + componentSortingFn, constantTableOptions, expanderId, isClickable, @@ -59,6 +60,7 @@ export const ContentTable = (contentTableProps: ContentTableProps): React.ReactE showRefresh = false, stickyLeftColumns = 0, viewId, + downloadToCsvFileName = null, onRowSelectionChange } = contentTableProps; const { @@ -86,6 +88,11 @@ export const ContentTable = (contentTableProps: ContentTableProps): React.ReactE const a = indexToNumber(rowA.getValue(columnId)); const b = indexToNumber(rowB.getValue(columnId)); return a > b ? -1 : a < b ? 1 : 0; + }, + [componentSortingFn]: (rowA: Row, rowB: Row, columnId: string) => { + const a = rowA.getValue(columnId) == null; + const b = rowB.getValue(columnId) == null; + return a === b ? 0 : a ? -1 : 1; } }, columnResizeMode: "onChange", @@ -181,6 +188,7 @@ export const ContentTable = (contentTableProps: ContentTableProps): React.ReactE columns={columns} expandableRows={insetColumns != null} showRefresh={showRefresh} + downloadToCsvFileName={downloadToCsvFileName} stickyLeftColumns={stickyLeftColumns} /> ) : null} diff --git a/Src/WitsmlExplorer.Frontend/components/ContentViews/table/Panel.tsx b/Src/WitsmlExplorer.Frontend/components/ContentViews/table/Panel.tsx index db1384447..0cc574544 100644 --- a/Src/WitsmlExplorer.Frontend/components/ContentViews/table/Panel.tsx +++ b/Src/WitsmlExplorer.Frontend/components/ContentViews/table/Panel.tsx @@ -1,11 +1,12 @@ import { Button, Icon, Typography } from "@equinor/eds-core-react"; import { Table } from "@tanstack/react-table"; -import React, { useContext, useState } from "react"; +import React, { useCallback, useContext, useState } from "react"; import styled from "styled-components"; import { ContentTableColumn } from "."; import ModificationType from "../../../contexts/modificationType"; import NavigationContext from "../../../contexts/navigationContext"; import OperationContext from "../../../contexts/operationContext"; +import useExport from "../../../hooks/useExport"; import ObjectService from "../../../services/objectService"; import { ColumnOptionsMenu } from "./ColumnOptionsMenu"; @@ -20,16 +21,30 @@ export interface PanelProps { columns?: ContentTableColumn[]; expandableRows?: boolean; stickyLeftColumns?: number; + downloadToCsvFileName?: string; } const Panel = (props: PanelProps) => { - const { checkableRows, panelElements, numberOfCheckedItems, numberOfItems, showRefresh, table, viewId, columns, expandableRows = false, stickyLeftColumns } = props; + const { + checkableRows, + panelElements, + numberOfCheckedItems, + numberOfItems, + showRefresh, + table, + viewId, + columns, + expandableRows = false, + downloadToCsvFileName = null, + stickyLeftColumns + } = props; const { navigationState, dispatchNavigation } = useContext(NavigationContext); const { operationState: { colors } } = useContext(OperationContext); const { selectedWellbore, selectedObjectGroup } = navigationState; const [isRefreshing, setIsRefreshing] = useState(false); + const { exportData, exportOptions } = useExport(); const selectedItemsText = checkableRows ? `Selected: ${numberOfCheckedItems}/${numberOfItems}` : `Items: ${numberOfItems}`; @@ -42,6 +57,23 @@ const Panel = (props: PanelProps) => { setIsRefreshing(false); }; + const exportAsCsv = useCallback(() => { + const exportColumns = table + .getVisibleLeafColumns() + .map((c) => c.id) + .join(exportOptions.separator); + const csvString = table + .getRowModel() + .rows.map((row) => + row + .getVisibleCells() + .map((cell) => cell.getValue()) + .join(exportOptions.separator) + ) + .join(exportOptions.newLineCharacter); + exportData(downloadToCsvFileName, exportColumns, csvString); + }, [columns, table]); + return (
@@ -59,6 +91,11 @@ const Panel = (props: PanelProps) => { Refresh )} + {downloadToCsvFileName != null && ( + + )} {panelElements}
); diff --git a/Src/WitsmlExplorer.Frontend/components/ContentViews/table/contentTableUtils.ts b/Src/WitsmlExplorer.Frontend/components/ContentViews/table/contentTableUtils.ts index f630cfb0b..ded396f14 100644 --- a/Src/WitsmlExplorer.Frontend/components/ContentViews/table/contentTableUtils.ts +++ b/Src/WitsmlExplorer.Frontend/components/ContentViews/table/contentTableUtils.ts @@ -7,6 +7,7 @@ export const selectId = "select"; export const expanderId = "expander"; export const activeId = "active"; //implemented specifically for LogCurveInfoListView, needs rework if other views will also use filtering export const measureSortingFn = "measure"; +export const componentSortingFn = "component"; export const constantTableOptions = { enableColumnResizing: true, diff --git a/Src/WitsmlExplorer.Frontend/components/ContentViews/table/tableParts.ts b/Src/WitsmlExplorer.Frontend/components/ContentViews/table/tableParts.ts index 6f2155203..fe39a900c 100644 --- a/Src/WitsmlExplorer.Frontend/components/ContentViews/table/tableParts.ts +++ b/Src/WitsmlExplorer.Frontend/components/ContentViews/table/tableParts.ts @@ -27,6 +27,7 @@ export interface ContentTableProps { showRefresh?: boolean; stickyLeftColumns?: number; // how many columns should be sticky viewId?: string; //id that will be used to save view settings to local storage, or null if should not save + downloadToCsvFileName?: string; } export enum Order { @@ -38,5 +39,6 @@ export enum ContentType { String, Number, DateTime, - Measure + Measure, + Component } diff --git a/Src/WitsmlExplorer.Frontend/components/ContextMenus/LogObjectContextMenu.tsx b/Src/WitsmlExplorer.Frontend/components/ContextMenus/LogObjectContextMenu.tsx index 257a510c6..66672e62b 100644 --- a/Src/WitsmlExplorer.Frontend/components/ContextMenus/LogObjectContextMenu.tsx +++ b/Src/WitsmlExplorer.Frontend/components/ContextMenus/LogObjectContextMenu.tsx @@ -6,9 +6,11 @@ import NavigationContext from "../../contexts/navigationContext"; import OperationContext from "../../contexts/operationContext"; import OperationType from "../../contexts/operationType"; import { ComponentType } from "../../models/componentType"; +import CheckLogHeaderJob from "../../models/jobs/checkLogHeaderJob"; import { CopyRangeClipboard } from "../../models/jobs/componentReferences"; import { CopyComponentsJob } from "../../models/jobs/copyJobs"; import ObjectReference from "../../models/jobs/objectReference"; +import LogObject from "../../models/logObject"; import ObjectOnWellbore, { toObjectReference } from "../../models/objectOnWellbore"; import { ObjectType } from "../../models/objectType"; import { Server } from "../../models/server"; @@ -20,9 +22,10 @@ import LogDataImportModal, { LogDataImportModalProps } from "../Modals/LogDataIm import LogPropertiesModal from "../Modals/LogPropertiesModal"; import { PropertiesModalMode } from "../Modals/ModalParts"; import ObjectPickerModal, { ObjectPickerProps } from "../Modals/ObjectPickerModal"; +import { ReportModal } from "../Modals/ReportModal"; import TrimLogObjectModal, { TrimLogObjectModalProps } from "../Modals/TrimLogObject/TrimLogObjectModal"; import ContextMenu from "./ContextMenu"; -import { menuItemText, StyledIcon } from "./ContextMenuUtils"; +import { StyledIcon, menuItemText } from "./ContextMenuUtils"; import { onClickPaste } from "./CopyUtils"; import { ObjectContextMenuProps, ObjectMenuItems } from "./ObjectMenuItems"; import { useClipboardComponentReferencesOfType } from "./UseClipboardComponentReferences"; @@ -89,6 +92,15 @@ const LogObjectContextMenu = (props: ObjectContextMenuProps): React.ReactElement }); }; + const onClickCheckHeader = async () => { + dispatchOperation({ type: OperationType.HideContextMenu }); + const logReference: LogObject = checkedObjects[0]; + const checkLogHeaderJob: CheckLogHeaderJob = { logReference }; + const jobId = await JobService.orderJob(JobType.CheckLogHeader, checkLogHeaderJob); + const reportModalProps = { jobId }; + dispatchOperation({ type: OperationType.DisplayModal, payload: }); + }; + return ( Import log data from .csv , + + + {`${menuItemText("check", "log header", [])}`} + , , diff --git a/Src/WitsmlExplorer.Frontend/components/Modals/ReportModal.tsx b/Src/WitsmlExplorer.Frontend/components/Modals/ReportModal.tsx new file mode 100644 index 000000000..e7c2f9a9b --- /dev/null +++ b/Src/WitsmlExplorer.Frontend/components/Modals/ReportModal.tsx @@ -0,0 +1,122 @@ +import { DotProgress, Typography } from "@equinor/eds-core-react"; +import React, { useContext, useEffect, useState } from "react"; +import styled from "styled-components"; +import NavigationContext from "../../contexts/navigationContext"; +import OperationContext from "../../contexts/operationContext"; +import OperationType from "../../contexts/operationType"; +import BaseReport, { createReport } from "../../models/reports/BaseReport"; +import JobService from "../../services/jobService"; +import NotificationService from "../../services/notificationService"; +import { ContentTable, ContentTableColumn, ContentType } from "../ContentViews/table"; +import ModalDialog, { ModalWidth } from "./ModalDialog"; + +export interface ReportModal { + report?: BaseReport; + jobId?: string; +} + +/** + * A modal component to display a report. + * + * Either `report` or `jobId` must be set, but not both. If `jobId` is set, the component will subscribe to NotificationService events + * until the job has finished or failed. + * + * @component + * @param {BaseReport} props.report - The report to display. + * @param {string} props.jobId - The ID of the job to monitor. + * + * @returns {React.ReactElement} The rendered ReportModal component. + */ +export const ReportModal = (props: ReportModal): React.ReactElement => { + const { jobId, report: reportProp } = props; + const { operationState, dispatchOperation } = React.useContext(OperationContext); + const { colors } = operationState; + const [report, setReport] = useState(reportProp); + const fetchedReport = useGetReportOnJobFinished(jobId); + + useEffect(() => { + if (fetchedReport) setReport(fetchedReport); + }, [fetchedReport]); + + const columns: ContentTableColumn[] = React.useMemo( + () => + report && report.reportItems.length > 0 + ? Object.keys(report.reportItems[0]).map((key) => ({ + property: key, + label: key, + type: ContentType.String + })) + : [], + [report] + ); + + return ( + + {report ? ( + + {report.summary && {report.summary}} + {columns.length > 0 && } + + ) : ( + +
+ Waiting for the job to finish. + +
+ The report will also be available in the jobs view once the job is finished. +
+ )} + + } + onSubmit={() => dispatchOperation({ type: OperationType.HideModal })} + isLoading={false} + /> + ); +}; + +export const useGetReportOnJobFinished = (jobId: string): BaseReport => { + const { navigationState } = useContext(NavigationContext); + const [report, setReport] = useState(null); + + if (!jobId) return null; + + useEffect(() => { + const unsubscribeOnJobFinished = NotificationService.Instance.snackbarDispatcherAsEvent.subscribe(async (notification) => { + if (notification.jobId === jobId) { + const jobInfo = await JobService.getUserJobInfo(notification.jobId); + if (!jobInfo) { + setReport(createReport(`The job has finished, but could not find job info for job ${jobId}`)); + } else { + setReport(jobInfo.report); + } + } + }); + const unsubscribeOnJobFailed = NotificationService.Instance.alertDispatcherAsEvent.subscribe(async (notification) => { + if (notification.jobId === jobId) { + setReport(createReport(notification.message, notification.reason)); + } + }); + + return function cleanup() { + unsubscribeOnJobFinished(); + unsubscribeOnJobFailed(); + }; + }, [navigationState.selectedServer, jobId]); + + return report; +}; + +const ContentLayout = styled.div` + display: flex; + flex-direction: column; + gap: 0.5em; + justify-content: space-between; + margin: 1em 0.2em 1em 0.2em; + max-height: 65vh; +`; diff --git a/Src/WitsmlExplorer.Frontend/components/Modals/SelectIndexToDisplayModal.tsx b/Src/WitsmlExplorer.Frontend/components/Modals/SelectIndexToDisplayModal.tsx index 14e889f6e..c952b8f60 100644 --- a/Src/WitsmlExplorer.Frontend/components/Modals/SelectIndexToDisplayModal.tsx +++ b/Src/WitsmlExplorer.Frontend/components/Modals/SelectIndexToDisplayModal.tsx @@ -90,6 +90,7 @@ const SelectIndexToDisplayModal = (props: SelectIndexToDisplayModalProps): React }; const indexToNumber = (index: string): number => { + if (!index) return null; return Number(index.replace(/[^\d.-]/g, "")); }; diff --git a/Src/WitsmlExplorer.Frontend/components/Modals/TrimLogObject/AdjustDateTimeModal.tsx b/Src/WitsmlExplorer.Frontend/components/Modals/TrimLogObject/AdjustDateTimeModal.tsx index dbbf4372c..9aa161e90 100644 --- a/Src/WitsmlExplorer.Frontend/components/Modals/TrimLogObject/AdjustDateTimeModal.tsx +++ b/Src/WitsmlExplorer.Frontend/components/Modals/TrimLogObject/AdjustDateTimeModal.tsx @@ -74,7 +74,7 @@ const AdjustDateTimeModal = (props: AdjustDateTimeModelProps): React.ReactElemen { setStartIndex(dateTime); @@ -84,7 +84,7 @@ const AdjustDateTimeModal = (props: AdjustDateTimeModelProps): React.ReactElemen maxValue={endIndex} /> { setEndIndex(dateTime); diff --git a/Src/WitsmlExplorer.Frontend/components/Modals/TrimLogObject/AdjustNumberRangeModal.tsx b/Src/WitsmlExplorer.Frontend/components/Modals/TrimLogObject/AdjustNumberRangeModal.tsx index 993dbe3ad..9766d249d 100644 --- a/Src/WitsmlExplorer.Frontend/components/Modals/TrimLogObject/AdjustNumberRangeModal.tsx +++ b/Src/WitsmlExplorer.Frontend/components/Modals/TrimLogObject/AdjustNumberRangeModal.tsx @@ -75,7 +75,7 @@ const AdjustNumberRangeModal = (props: AdjustNumberRangeModalProps): React.React { + //mock ResizeObserver to enable testing virtualized components + window.ResizeObserver = ResizeObserver; + + describe("Report Modal with report", () => { + it("Should show a basic report", () => { + renderWithContexts(); + expect(screen.getByText(REPORT.title)).toBeInTheDocument(); + expect(screen.getByText(REPORT.summary)).toBeInTheDocument(); + expect(screen.getByRole("table")).toBeInTheDocument(); + }); + + it("Should be able to show a report without reportItems", () => { + renderWithContexts(); + expect(screen.getByText(EMPTY_REPORT.title)).toBeInTheDocument(); + expect(screen.getByText(EMPTY_REPORT.summary)).toBeInTheDocument(); + expect(screen.queryByRole("table")).not.toBeInTheDocument(); + }); + + it("Should show the reportItems in a table", () => { + renderWithContexts(); + const rows = screen.getAllByRole("row"); + expect(rows).toHaveLength(REPORT_ITEMS.length + 1); // An extra row for the header + + // Test that the header has the keys as values in each cell + const headerCells = within(rows[0]).getAllByRole("button"); // header cells are buttons to toggle sorting + expect(headerCells).toHaveLength(Object.keys(REPORT_ITEMS[0]).length); + Object.keys(REPORT_ITEMS[0]).forEach((key, cellIndex) => { + expect(within(headerCells[cellIndex]).getByText(key)).toBeInTheDocument(); + }); + + // Test the data + REPORT_ITEMS.forEach((reportItem, rowIndex) => { + const cells = within(rows[rowIndex + 1]).getAllByRole("cell"); + expect(cells).toHaveLength(Object.keys(reportItem).length + 2); // +2 because ContentTable adds an extra column before and after + Object.values(reportItem).forEach((value, cellIndex) => { + expect(within(cells[cellIndex + 1]).getByText(value)).toBeInTheDocument(); // +1 for the same reason + }); + }); + }); + }); + + describe("Report Modal with jobId", () => { + it("Should show a loading screen when provided with a jobId of an unfinished job", () => { + renderWithContexts(); + expect(screen.getByText(/loading report/i)).toBeInTheDocument(); + expect(screen.getByRole("progressbar")).toBeInTheDocument(); + expect(screen.queryByRole("table")).not.toBeInTheDocument(); + }); + + it("Should show the report once the job has finished", async () => { + const { promise: jobInfoPromise, resolve: resolveJobInfoPromise } = deferred(); + + jest.spyOn(JobService, "getUserJobInfo").mockImplementation(() => jobInfoPromise); + + renderWithContexts(); + expect(screen.getByText(/loading report/i)).toBeInTheDocument(); + + // Send the mocked notification signal + NotificationService.Instance.snackbarDispatcher.dispatch(NOTIFICATION); + + // A notification that the job has finished has been received. It should still display loading until the job is fetched. + expect(screen.getByText(/loading report/i)).toBeInTheDocument(); + expect(JobService.getUserJobInfo).toHaveBeenCalledTimes(1); + + // Resolve and return from the mocked getUserJobInfo + await act(async () => { + resolveJobInfoPromise(JOB_INFO); + }); + + expect(screen.queryByText(/loading report/i)).not.toBeInTheDocument(); + expect(screen.getByText(REPORT.title)).toBeInTheDocument(); + expect(screen.getByText(REPORT.summary)).toBeInTheDocument(); + }); + }); +}); + +const REPORT_ITEMS = [ + { + field1: "value1_a", + field2: "value1_b", + field3: "value1_c" + }, + { + field1: "value2_a", + field2: "value2_b", + field3: "value2_c" + }, + { + field1: "value3_a", + field2: "value3_b", + field3: "value3_c" + } +]; + +const REPORT = createReport("testTitle", "testSummary", REPORT_ITEMS); +const EMPTY_REPORT = createReport("emptyReportTitle", "emptyReportSummary"); +const JOB_INFO = getJobInfo({ report: REPORT, id: "testJobId" }); +const NOTIFICATION = getNotification({ jobId: "testJobId" }); diff --git a/Src/WitsmlExplorer.Frontend/hooks/useExport.ts b/Src/WitsmlExplorer.Frontend/hooks/useExport.ts index 992c3cb16..8318f57ff 100644 --- a/Src/WitsmlExplorer.Frontend/hooks/useExport.ts +++ b/Src/WitsmlExplorer.Frontend/hooks/useExport.ts @@ -1,3 +1,5 @@ +import { useCallback, useMemo } from "react"; + export interface ExportProperties { outputMimeType: string; fileExtension: string; @@ -6,9 +8,19 @@ export interface ExportProperties { omitSpecialCharactersFromFilename?: boolean; appendDateTime?: boolean; } + +const defaultExportProperties = { + fileExtension: ".csv", + newLineCharacter: "\n", + outputMimeType: "text/csv", + separator: ",", + omitSpecialCharactersFromFilename: true, + appendDateTime: true +}; + interface ExportObject { exportData: (fileName: string, header: string, data: string) => void; - properties: ExportProperties; + exportOptions: ExportProperties; } function omitSpecialCharacters(text: string): string { return text.replace(/[&/\\#,+()$~%.'":*?<>{}]/g, "_"); @@ -17,26 +29,32 @@ function appendDateTime(append: boolean): string { const now = new Date(); return append ? `-${now.getFullYear()}-${now.getMonth()}-${now.getDay()}T${now.getHours()}-${now.getMinutes()}-${now.getSeconds()}` : ""; } -function useExport(props: ExportProperties): ExportObject { - const exportData = (fileName: string, header: string, data: string) => { - const link = document.createElement("a"); - link.href = window.URL.createObjectURL( - new Blob([header, props.newLineCharacter, data], { - type: props.outputMimeType - }) - ); - link.download = props.omitSpecialCharactersFromFilename - ? `${omitSpecialCharacters(fileName)}${appendDateTime(props.appendDateTime)}${props.fileExtension}` - : `${fileName}${appendDateTime(props.appendDateTime)}${props.fileExtension}`; - document.body.appendChild(link); - link.click(); - //we might not need a timeout for clean up - setTimeout(function () { - window.URL.revokeObjectURL(link.href); - link.remove(); - }, 200); - }; - return { exportData: exportData, properties: props }; +function useExport(props?: Partial): ExportObject { + const exportOptions = useMemo(() => ({ ...defaultExportProperties, ...props }), [defaultExportProperties, props]); + + const exportData = useCallback( + (fileName: string, header: string, data: string) => { + const link = document.createElement("a"); + link.href = window.URL.createObjectURL( + new Blob([header, exportOptions.newLineCharacter, data], { + type: exportOptions.outputMimeType + }) + ); + link.download = exportOptions.omitSpecialCharactersFromFilename + ? `${omitSpecialCharacters(fileName)}${appendDateTime(exportOptions.appendDateTime)}${exportOptions.fileExtension}` + : `${fileName}${appendDateTime(exportOptions.appendDateTime)}${exportOptions.fileExtension}`; + document.body.appendChild(link); + link.click(); + //we might not need a timeout for clean up + setTimeout(function () { + window.URL.revokeObjectURL(link.href); + link.remove(); + }, 200); + }, + [exportOptions] + ); + + return useMemo(() => ({ exportData, exportOptions }), [exportData, exportOptions]); } export default useExport; diff --git a/Src/WitsmlExplorer.Frontend/models/jobs/checkLogHeaderJob.tsx b/Src/WitsmlExplorer.Frontend/models/jobs/checkLogHeaderJob.tsx new file mode 100644 index 000000000..031138226 --- /dev/null +++ b/Src/WitsmlExplorer.Frontend/models/jobs/checkLogHeaderJob.tsx @@ -0,0 +1,5 @@ +import LogObject from "../logObject"; + +export default interface CheckLogHeaderJob { + logReference: LogObject; +} diff --git a/Src/WitsmlExplorer.Frontend/models/jobs/jobInfo.tsx b/Src/WitsmlExplorer.Frontend/models/jobs/jobInfo.tsx index efedd5fd1..73853bafd 100644 --- a/Src/WitsmlExplorer.Frontend/models/jobs/jobInfo.tsx +++ b/Src/WitsmlExplorer.Frontend/models/jobs/jobInfo.tsx @@ -1,3 +1,5 @@ +import BaseReport from "../reports/BaseReport"; + export default interface JobInfo { jobType: string; description: string; @@ -15,4 +17,5 @@ export default interface JobInfo { killTime: string; status: string; failedReason: string; + report: BaseReport; } diff --git a/Src/WitsmlExplorer.Frontend/models/reports/BaseReport.tsx b/Src/WitsmlExplorer.Frontend/models/reports/BaseReport.tsx new file mode 100644 index 000000000..4ffb2b91c --- /dev/null +++ b/Src/WitsmlExplorer.Frontend/models/reports/BaseReport.tsx @@ -0,0 +1,13 @@ +export default interface BaseReport { + title: string; + summary: string; + reportItems: any[]; +} + +export const createReport = (title = "", summary = "", reportItems: any[] = []): BaseReport => { + return { + title, + summary, + reportItems + }; +}; diff --git a/Src/WitsmlExplorer.Frontend/services/jobService.tsx b/Src/WitsmlExplorer.Frontend/services/jobService.tsx index f75a049d3..09c5e9f10 100644 --- a/Src/WitsmlExplorer.Frontend/services/jobService.tsx +++ b/Src/WitsmlExplorer.Frontend/services/jobService.tsx @@ -23,7 +23,7 @@ export default class JobService { message: `Ordered ${jobType} job`, isSuccess: true }); - return response.body; + return response.json(); } else { NotificationService.Instance.snackbarDispatcher.dispatch({ serverUrl: new URL(server?.url), @@ -34,6 +34,15 @@ export default class JobService { } } + public static async getUserJobInfo(jobId: string, abortSignal?: AbortSignal): Promise { + const response = await ApiClient.get(`/api/jobs/userjobinfo/${jobId}`, abortSignal); + if (response.ok) { + return response.json(); + } else { + return null; + } + } + public static async getUserJobInfos(abortSignal?: AbortSignal): Promise { const response = await ApiClient.get(`/api/jobs/userjobinfos`, abortSignal); if (response.ok) { @@ -54,6 +63,7 @@ export default class JobService { } export enum JobType { + CheckLogHeader = "CheckLogHeader", CreateWell = "CreateWell", CopyComponents = "CopyComponents", CopyLogData = "CopyLogData", diff --git a/Src/WitsmlExplorer.Frontend/services/notificationService.ts b/Src/WitsmlExplorer.Frontend/services/notificationService.ts index 14f2cc89d..23df4881d 100644 --- a/Src/WitsmlExplorer.Frontend/services/notificationService.ts +++ b/Src/WitsmlExplorer.Frontend/services/notificationService.ts @@ -12,6 +12,7 @@ export interface Notification { severity?: AlertSeverity; reason?: string; description?: ObjectDescription; + jobId?: string; } interface ObjectDescription { @@ -64,30 +65,30 @@ export default class NotificationService { .withUrl(`${notificationURL}notifications`, { accessTokenFactory: msalEnabled ? () => NotificationService.getToken() : undefined }) - .withAutomaticReconnect([3000, 5000, 10000]) - .configureLogging(signalR.LogLevel.None) - .build(); + ?.withAutomaticReconnect([3000, 5000, 10000]) + ?.configureLogging(signalR.LogLevel.None) + ?.build(); - this.hubConnection.on("jobFinished", (notification: Notification) => { + this.hubConnection?.on("jobFinished", (notification: Notification) => { notification.isSuccess ? this._snackbarDispatcher.dispatch(notification) : this._alertDispatcher.dispatch(notification); }); - this.hubConnection.on("refresh", (refreshAction: RefreshAction) => { + this.hubConnection?.on("refresh", (refreshAction: RefreshAction) => { this._refreshDispatcher.dispatch(refreshAction); }); - this.hubConnection.onreconnecting(() => { + this.hubConnection?.onreconnecting(() => { this._onConnectionStateChanged.dispatch(false); }); - this.hubConnection.onreconnected(() => { + this.hubConnection?.onreconnected(() => { this._onConnectionStateChanged.dispatch(true); }); - this.hubConnection.onclose(() => { + this.hubConnection?.onclose(() => { NotificationService.token = null; setTimeout(() => this.hubConnection.start(), 5000); }); - this.hubConnection.start(); + this.hubConnection?.start(); } public get snackbarDispatcher(): SimpleEventDispatcher { diff --git a/Tests/WitsmlExplorer.Api.Tests/Workers/CheckLogHeaderWorkerTests.cs b/Tests/WitsmlExplorer.Api.Tests/Workers/CheckLogHeaderWorkerTests.cs new file mode 100644 index 000000000..d6727efc9 --- /dev/null +++ b/Tests/WitsmlExplorer.Api.Tests/Workers/CheckLogHeaderWorkerTests.cs @@ -0,0 +1,379 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +using Microsoft.Extensions.Logging; + +using Moq; + +using Serilog; + +using Witsml; +using Witsml.Data; +using Witsml.Extensions; +using Witsml.ServiceReference; + +using WitsmlExplorer.Api.Jobs; +using WitsmlExplorer.Api.Models; +using WitsmlExplorer.Api.Models.Reports; +using WitsmlExplorer.Api.Services; +using WitsmlExplorer.Api.Workers; + +using Xunit; + +namespace WitsmlExplorer.Api.Tests.Workers +{ + public class CheckLogHeaderWorkerTests + { + private const string LogUid = "8cfad887-3e81-40f0-9034-178be642df65"; + private const string LogName = "Test log"; + private const string WellUid = "W-5209671"; + private const string WellboreUid = "B-5209671"; + private const string DepthDataRow1 = "100,501"; + private const string DepthDataRow2 = "101,51,502"; + private const string DepthDataRow3 = "102,52,"; + private const string DepthDataFirstRowForTQ = "101,51"; + private const string TimeDataRow1 = "2023-04-19T00:00:00Z,501"; + private const string TimeDataRow2 = "2023-04-19T00:00:01Z,51,502"; + private const string TimeDataRow3 = "2023-04-19T00:00:02Z,52,"; + private const string TimeDataFirstRowForTQ = "2023-04-19T00:00:01Z,51"; + private readonly Mock _witsmlClient; + private readonly CheckLogHeaderWorker _worker; + + public CheckLogHeaderWorkerTests() + { + Mock witsmlClientProvider = new(); + _witsmlClient = new Mock(); + witsmlClientProvider.Setup(provider => provider.GetClient()).Returns(_witsmlClient.Object); + ILoggerFactory loggerFactory = new LoggerFactory(); + loggerFactory.AddSerilog(Log.Logger); + ILogger logger = loggerFactory.CreateLogger(); + _worker = new CheckLogHeaderWorker(logger, witsmlClientProvider.Object); + } + + [Fact] + public async Task CheckLogHeader_Depth_GetHeaderValues_ReturnsIndexes() + { + SetupClient(_witsmlClient, WitsmlLog.WITSML_INDEX_TYPE_MD, shouldBeConsistent: true); + + (Dictionary, Dictionary, string, string, string)? result = await _worker.GetHeaderValues(WellUid, WellboreUid, LogUid, true); + Assert.NotNull(result); + (Dictionary headerStartValues, Dictionary headerEndValues, string headerStartIndex, string headerEndIndex, string indexCurve) = result.Value; + Assert.Equal(3, headerStartValues.Count); + Assert.Equal(3, headerEndValues.Count); + Assert.Equal("100", headerStartIndex); + Assert.Equal("102", headerEndIndex); + Assert.Equal("101", headerStartValues["TQ"]); + Assert.Equal("Depth", indexCurve); + } + + [Fact] + public async Task CheckLogHeader_Depth_GetDataValues_ReturnsIndexes() + { + SetupClient(_witsmlClient, WitsmlLog.WITSML_INDEX_TYPE_MD, shouldBeConsistent: true); + + (Dictionary, Dictionary, string, string)? result = await _worker.GetDataValues(WellUid, WellboreUid, LogUid, WitsmlLog.WITSML_INDEX_TYPE_MD, "Depth"); + Assert.NotNull(result); + (Dictionary dataStartValues, Dictionary dataEndValues, string dataStartIndex, string dataEndIndex) = result.Value; + Assert.Equal(3, dataStartValues.Count); + Assert.Equal(3, dataEndValues.Count); + Assert.Equal("100", dataStartIndex); + Assert.Equal("102", dataEndIndex); + Assert.Equal("101", dataStartValues["TQ"]); + } + + [Fact] + public void CheckLogHeader_Depth_GetMismatchingIndexes_NoMismatch_IsEmpty() + { + Dictionary headerStartValues = new() + { + { "Depth", "0" }, + { "Curve", "1" } + }; + Dictionary headerEndValues = new() + { + { "Depth", "2" }, + { "Curve", "2" } + }; + Dictionary dataStartValues = headerStartValues; + Dictionary dataEndValues = headerEndValues; + const string headerStartIndex = "0"; + const string headerEndIndex = "2"; + const string dataStartIndex = headerStartIndex; + const string dataEndIndex = headerEndIndex; + + List mismatchingIndexes = CheckLogHeaderWorker.GetMismatchingIndexes(headerStartValues, headerEndValues, dataStartValues, dataEndValues, headerStartIndex, headerEndIndex, dataStartIndex, dataEndIndex, true); + Assert.Empty(mismatchingIndexes); + } + + [Fact] + public void CheckLogHeader_Depth_GetMismatchingIndexes_AddsMismatches() + { + Dictionary headerStartValues = new() + { + { "Depth", "0" }, + { "Curve", "1" } + }; + Dictionary headerEndValues = new() + { + { "Depth", "2" }, + { "Curve", "2" } + }; + Dictionary dataStartValues = new() + { + { "Depth", "0" }, + { "Curve", "1" } + }; + Dictionary dataEndValues = new() + { + { "Depth", "1" }, + { "Curve", "1" } + }; + const string headerStartIndex = "0"; + const string headerEndIndex = "1"; + const string dataStartIndex = "0"; + const string dataEndIndex = "1"; + + List mismatchingIndexes = CheckLogHeaderWorker.GetMismatchingIndexes(headerStartValues, headerEndValues, dataStartValues, dataEndValues, headerStartIndex, headerEndIndex, dataStartIndex, dataEndIndex, true); + + Assert.Equal(2, mismatchingIndexes.Count); + } + + [Fact] + public async Task CheckLogHeader_Depth_CorrectData_IsValid() + { + CheckLogHeaderJob job = CreateJobTemplate(WitsmlLog.WITSML_INDEX_TYPE_MD); + JobInfo jobInfo = new(); + job.JobInfo = jobInfo; + + SetupClient(_witsmlClient, WitsmlLog.WITSML_INDEX_TYPE_MD, shouldBeConsistent: true); + (_, _) = await _worker.Execute(job); + + Assert.IsType(jobInfo.Report); + CheckLogHeaderReport report = (CheckLogHeaderReport)jobInfo.Report; + Assert.Equal(LogUid, report.LogReference.Uid); + Assert.Equal(WellUid, report.LogReference.WellUid); + Assert.Equal(WellboreUid, report.LogReference.WellboreUid); + Assert.Empty(report.ReportItems); + } + + [Fact] + public async Task CheckLogHeader_Depth_IncorrectData_IsInvalid() + { + CheckLogHeaderJob job = CreateJobTemplate(WitsmlLog.WITSML_INDEX_TYPE_MD); + JobInfo jobInfo = new(); + job.JobInfo = jobInfo; + + + SetupClient(_witsmlClient, WitsmlLog.WITSML_INDEX_TYPE_MD, shouldBeConsistent: false); + (_, _) = await _worker.Execute(job); + + Assert.IsType(jobInfo.Report); + CheckLogHeaderReport report = (CheckLogHeaderReport)jobInfo.Report; + Assert.Equal(LogUid, report.LogReference.Uid); + Assert.Equal(WellUid, report.LogReference.WellUid); + Assert.Equal(WellboreUid, report.LogReference.WellboreUid); + List reportItems = (List)report.ReportItems; + Assert.Single(reportItems); + Assert.Equal("TQ", reportItems[0].Mnemonic); + Assert.Equal("101", reportItems[0].HeaderEndIndex); + Assert.Equal("102", reportItems[0].DataEndIndex); + Assert.Equal("101", reportItems[0].HeaderStartIndex); + Assert.Equal("101", reportItems[0].DataStartIndex); + } + + [Fact] + public async Task CheckLogHeader_Time_CorrectData_IsValid() + { + CheckLogHeaderJob job = CreateJobTemplate(WitsmlLog.WITSML_INDEX_TYPE_DATE_TIME); + JobInfo jobInfo = new(); + job.JobInfo = jobInfo; + + SetupClient(_witsmlClient, WitsmlLog.WITSML_INDEX_TYPE_DATE_TIME, shouldBeConsistent: true); + (_, _) = await _worker.Execute(job); + + Assert.IsType(jobInfo.Report); + CheckLogHeaderReport report = (CheckLogHeaderReport)jobInfo.Report; + Assert.Equal(LogUid, report.LogReference.Uid); + Assert.Equal(WellUid, report.LogReference.WellUid); + Assert.Equal(WellboreUid, report.LogReference.WellboreUid); + Assert.Empty(report.ReportItems); + } + + [Fact] + public async Task CheckLogHeader_Time_IncorrectData_IsInvalid() + { + CheckLogHeaderJob job = CreateJobTemplate(WitsmlLog.WITSML_INDEX_TYPE_DATE_TIME); + JobInfo jobInfo = new(); + job.JobInfo = jobInfo; + + + SetupClient(_witsmlClient, WitsmlLog.WITSML_INDEX_TYPE_DATE_TIME, shouldBeConsistent: false); + (_, _) = await _worker.Execute(job); + + Assert.IsType(jobInfo.Report); + CheckLogHeaderReport report = (CheckLogHeaderReport)jobInfo.Report; + Assert.Equal(LogUid, report.LogReference.Uid); + Assert.Equal(WellUid, report.LogReference.WellUid); + Assert.Equal(WellboreUid, report.LogReference.WellboreUid); + List reportItems = (List)report.ReportItems; + Assert.Single(reportItems); + Assert.Equal("TQ", reportItems[0].Mnemonic); + Assert.Equal("2023-04-19T00:00:01Z", reportItems[0].HeaderEndIndex); + Assert.Equal("2023-04-19T00:00:02Z", reportItems[0].DataEndIndex); + Assert.Equal("2023-04-19T00:00:01Z", reportItems[0].HeaderStartIndex); + Assert.Equal("2023-04-19T00:00:01Z", reportItems[0].DataStartIndex); + } + + private static void SetupClient(Mock witsmlClient, string indexType, bool shouldBeConsistent) + { + witsmlClient.Setup(client => + client.GetFromStoreNullableAsync(It.IsAny(), It.IsAny())) + .Returns((WitsmlLogs logs, OptionsIn options) => + { + if (options.MaxReturnNodes == 1) + { + if (logs.Logs[0].LogData.MnemonicList == "") + { + return Task.FromResult(GetTestLogDataFirstRow(indexType)); + } + return Task.FromResult(GetTestLogDataFirstRowForMnemonic(indexType)); + } + else if (options.RequestLatestValues == 1) + { + return Task.FromResult(GetTestLogDataLatestValues(indexType)); + } + return Task.FromResult(GetTestLogHeader(shouldBeConsistent)); + }); + } + + private static CheckLogHeaderJob CreateJobTemplate(string indexType) + { + return new CheckLogHeaderJob + { + LogReference = new LogObject + { + Uid = LogUid, + Name = LogName, + WellUid = WellUid, + WellboreUid = WellboreUid, + IndexType = indexType, + } + }; + } + + public static WitsmlLogs GetTestLogHeader(bool shouldBeConsistent) + { + return new WitsmlLogs + { + Logs = new WitsmlLog + { + UidWell = WellUid, + UidWellbore = WellboreUid, + Uid = LogUid, + StartIndex = new WitsmlIndex("100"), + EndIndex = new WitsmlIndex("102"), + StartDateTimeIndex = "2023-04-19T00:00:00Z", + EndDateTimeIndex = "2023-04-19T00:00:02Z", + IndexCurve = new WitsmlIndexCurve() { Value = "Depth" }, + LogCurveInfo = new List() + { + new WitsmlLogCurveInfo() + { + Mnemonic = "Depth", + MinIndex = new WitsmlIndex("100"), + MaxIndex = new WitsmlIndex("102"), + MinDateTimeIndex = "2023-04-19T00:00:00Z", + MaxDateTimeIndex = "2023-04-19T00:00:02Z" + }, + new WitsmlLogCurveInfo() + { + Mnemonic = "TQ", + MinIndex = new WitsmlIndex("101"), + MaxIndex = new WitsmlIndex(shouldBeConsistent ? "102" : "101"), + MinDateTimeIndex = "2023-04-19T00:00:01Z", + MaxDateTimeIndex = shouldBeConsistent ? "2023-04-19T00:00:02Z" : "2023-04-19T00:00:01Z" + }, + new WitsmlLogCurveInfo() + { + Mnemonic = "ROP", + MinIndex = new WitsmlIndex("100"), + MaxIndex = new WitsmlIndex("101"), + MinDateTimeIndex = "2023-04-19T00:00:00Z", + MaxDateTimeIndex = "2023-04-19T00:00:01Z" + }, + }, + }.AsSingletonList() + }; + } + + public static WitsmlLogs GetTestLogDataLatestValues(string indexType) + { + bool isDepthLog = indexType == WitsmlLog.WITSML_INDEX_TYPE_MD; + return new WitsmlLogs + { + Logs = new WitsmlLog + { + UidWell = WellUid, + UidWellbore = WellboreUid, + Uid = LogUid, + LogData = new WitsmlLogData() + { + MnemonicList = "Depth,TQ,ROP", + Data = new List() + { + new WitsmlData(){Data = isDepthLog ? DepthDataRow2 : TimeDataRow2}, + new WitsmlData(){Data = isDepthLog ? DepthDataRow3 : TimeDataRow3} + } + } + }.AsSingletonList() + }; + } + + public static WitsmlLogs GetTestLogDataFirstRow(string indexType) + { + bool isDepthLog = indexType == WitsmlLog.WITSML_INDEX_TYPE_MD; + return new WitsmlLogs + { + Logs = new WitsmlLog + { + UidWell = WellUid, + UidWellbore = WellboreUid, + Uid = LogUid, + LogData = new WitsmlLogData() + { + MnemonicList = "Depth,ROP", + Data = new List() + { + new WitsmlData(){Data = isDepthLog ? DepthDataRow1 : TimeDataRow1}, + } + } + }.AsSingletonList() + }; + } + + public static WitsmlLogs GetTestLogDataFirstRowForMnemonic(string indexType) + { + bool isDepthLog = indexType == WitsmlLog.WITSML_INDEX_TYPE_MD; + return new WitsmlLogs + { + Logs = new WitsmlLog + { + UidWell = WellUid, + UidWellbore = WellboreUid, + Uid = LogUid, + LogData = new WitsmlLogData() + { + MnemonicList = "Depth,TQ", + Data = new List() + { + new WitsmlData() + { + Data = isDepthLog ? DepthDataFirstRowForTQ : TimeDataFirstRowForTQ + }, + } + } + }.AsSingletonList() + }; + } + } +}