diff --git a/.gitignore b/.gitignore index 362b36ce5..39950d2a8 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,9 @@ backend/*/obj backend/api/Database/Maps/* backend/backend.sln.DotSettings.user -#Broker +# Broker broker/mosquitto/config/certs/server-key.pem *.DS_Store + +# Local missions +missions/ diff --git a/backend/README.md b/backend/README.md index 519a3853b..b204899b4 100644 --- a/backend/README.md +++ b/backend/README.md @@ -32,11 +32,11 @@ To set up the backend on **Windows/Mac**, install visual studio and include the If you already have visual studio installed, you can open the "Visual Studio Installer" and modify your install to add the workload. To set up the backend on **Linux**, install .NET for linux -[here](https://docs.microsoft.com/en-us/dotnet/core/install/linux). -You need to also install the dev certificate for local .NET development on linux. +[here](https://docs.microsoft.com/en-us/dotnet/core/install/linux). +You need to also install the dev certificate for local .NET development on linux. Follow [this guide](https://learn.microsoft.com/en-us/aspnet/core/security/enforcing-ssl?view=aspnetcore-7.0&tabs=visual-studio%2Clinux-ubuntu#trust-https-certificate-on-linux), -for each of the browser(s) you wish to trust it in. +for each of the browser(s) you wish to trust it in. **NB:** You probably need to run the commands with `sudo` prefixed to have permission to change them. For the configuration to be able to read secrets from the keyvault, you will need to have the client secret stored locally in your secret manager. @@ -267,7 +267,7 @@ events and not receiving them, and all transmissions are sent using the SignalRS doing so it is important to make sure that the event name provided corresponds with the name expected in the frontend. -It is also crucial that we do not await sending signalR messages in our code. Instead we ignore the +It is also crucial that we do not await sending signalR messages in our code. Instead we ignore the await warning. In the current version of the SignalR library, sending a message in an asynchronous thread may cause the thread to silently exit without returning an exception, which is avoided by letting the SignalR code run asynchronously after the current thread has executed. @@ -280,6 +280,10 @@ to monitor the backend of our application. We have one application insight instance for each environment. The connection strings for the AI instances are stored in the keyvault. +## Custom Mission Loaders + +You can create your own mission loader to fetch missions from some external system. The custom mission loader needs to fulfill the [IMissionLoader](api/Services/MissionLoaders/MissionLoaderInterface.cs) interface. If you mission loader is an external API you might need to add it as a downstreamapi in [Program.cs](api/Program.cs) + ## Authorization We use role based access control (RBAC) for authorization. @@ -292,7 +296,7 @@ The access matrix looks like this: | Deck | Read | Read | CRUD | | Plant | Read | Read | CRUD | | Installation | Read | Read | CRUD | -| Echo | Read | Read | CRUD | +| MissionLoader | Read | Read | CRUD | | Missions | Read | Read | CRUD | | Robots | Read | Read | CRUD | | Robot Models | Read | Read | CRUD | diff --git a/backend/api.test/Client/AreaTests.cs b/backend/api.test/Client/AreaTests.cs index 97760eb71..37057ed29 100644 --- a/backend/api.test/Client/AreaTests.cs +++ b/backend/api.test/Client/AreaTests.cs @@ -216,7 +216,7 @@ public async Task MissionIsCreatedInArea() // Assert Assert.True(areaMissionsResponse.IsSuccessStatusCode); - var missions = await areaMissionsResponse.Content.ReadFromJsonAsync>(_serializerOptions); + var missions = await areaMissionsResponse.Content.ReadFromJsonAsync>(_serializerOptions); Assert.NotNull(missions); Assert.Single(missions.Where(m => m.Id.Equals(mission.MissionId, StringComparison.Ordinal))); } diff --git a/backend/api.test/Client/MissionTests.cs b/backend/api.test/Client/MissionTests.cs index 83b10ac40..85a633c82 100644 --- a/backend/api.test/Client/MissionTests.cs +++ b/backend/api.test/Client/MissionTests.cs @@ -261,7 +261,7 @@ private async Task PostInstallationInformationToDb(string installa } [Fact] - public async Task ScheduleOneEchoMissionTest() + public async Task ScheduleOneMissionTest() { // Arrange - Robot string robotUrl = "/robots"; @@ -274,13 +274,13 @@ public async Task ScheduleOneEchoMissionTest() string robotId = robot.Id; // Arrange - Area - string installationCode = "installationScheduleOneEchoMissionTest"; - string plantCode = "plantScheduleOneEchoMissionTest"; - string deckName = "deckScheduleOneEchoMissionTest"; - string areaName = "areaScheduleOneEchoMissionTest"; + string installationCode = "installationScheduleOneMissionTest"; + string plantCode = "plantScheduleOneMissionTest"; + string deckName = "deckScheduleOneMissionTest"; + string areaName = "areaScheduleOneMissionTest"; (_, _, _, _) = await PostAssetInformationToDb(installationCode, plantCode, deckName, areaName); - int echoMissionId = 95; + string missionId = "95"; // Act var query = new ScheduledMissionQuery @@ -288,7 +288,7 @@ public async Task ScheduleOneEchoMissionTest() RobotId = robotId, InstallationCode = installationCode, AreaName = areaName, - EchoMissionId = echoMissionId, + MissionId = missionId, DesiredStartTime = DateTime.UtcNow }; var content = new StringContent( @@ -308,7 +308,7 @@ public async Task ScheduleOneEchoMissionTest() } [Fact] - public async Task Schedule3EchoMissionsTest() + public async Task Schedule3MissionsTest() { // Arrange - Robot string robotUrl = "/robots"; @@ -321,13 +321,13 @@ public async Task Schedule3EchoMissionsTest() string robotId = robot.Id; // Arrange - Area - string installationCode = "installationSchedule3EchoMissionsTest"; - string plantCode = "plantSchedule3EchoMissionsTest"; - string deckName = "deckSchedule3EchoMissionsTest"; - string areaName = "areaSchedule3EchoMissionsTest"; + string installationCode = "installationSchedule3MissionsTest"; + string plantCode = "plantSchedule3MissionsTest"; + string deckName = "deckSchedule3MissionsTest"; + string areaName = "areaSchedule3MissionsTest"; (_, _, _, _) = await PostAssetInformationToDb(installationCode, plantCode, deckName, areaName); - int echoMissionId = 97; + string missionId = "97"; // Act var query = new ScheduledMissionQuery @@ -335,7 +335,7 @@ public async Task Schedule3EchoMissionsTest() RobotId = robotId, InstallationCode = installationCode, AreaName = areaName, - EchoMissionId = echoMissionId, + MissionId = missionId, DesiredStartTime = DateTime.UtcNow }; var content = new StringContent( @@ -645,16 +645,16 @@ public async Task GetNextRun() } [Fact] - public async Task ScheduleDuplicateEchoMissionDefinitions() + public async Task ScheduleDuplicatMissionDefinitions() { // Arrange - Initialise areas - string installationCode = "installationScheduleDuplicateEchoMissionDefinitions"; - string plantCode = "plantScheduleDuplicateEchoMissionDefinitions"; - string deckName = "deckScheduleDuplicateEchoMissionDefinitions"; - string areaName = "areaScheduleDuplicateEchoMissionDefinitions"; + string installationCode = "installationScheduleDuplicatMissionDefinitions"; + string plantCode = "plantScheduleDuplicatMissionDefinitions"; + string deckName = "deckScheduleDuplicatMissionDefinitions"; + string areaName = "areaScheduleDuplicatMissionDefinitions"; (_, _, _, _) = await PostAssetInformationToDb(installationCode, plantCode, deckName, areaName); - // Arrange - Create echo mission definition + // Arrange - Create mission definition string robotUrl = "/robots"; var response = await _client.GetAsync(robotUrl); Assert.True(response.IsSuccessStatusCode); @@ -662,14 +662,14 @@ public async Task ScheduleDuplicateEchoMissionDefinitions() Assert.NotNull(robots); var robot = robots.Where(robot => robot.Name == "Shockwave").First(); string robotId = robot.Id; - int echoMissionId = 1; // Corresponds to mock in EchoServiceMock.cs + string missionId = "986"; // Corresponds to mock in ServiceMock.cs var query = new ScheduledMissionQuery { RobotId = robotId, InstallationCode = installationCode, AreaName = areaName, - EchoMissionId = echoMissionId, + MissionId = missionId, DesiredStartTime = DateTime.UtcNow }; var content = new StringContent( @@ -679,9 +679,9 @@ public async Task ScheduleDuplicateEchoMissionDefinitions() ); // Act - string echoMissionsUrl = "/missions"; - var response1 = await _client.PostAsync(echoMissionsUrl, content); - var response2 = await _client.PostAsync(echoMissionsUrl, content); + string missionsUrl = "/missions"; + var response1 = await _client.PostAsync(missionsUrl, content); + var response2 = await _client.PostAsync(missionsUrl, content); // Assert Assert.True(response1.IsSuccessStatusCode); @@ -693,7 +693,6 @@ public async Task ScheduleDuplicateEchoMissionDefinitions() string? missionId1 = missionRun1.MissionId; string? missionId2 = missionRun2.MissionId; Assert.Equal(missionId1, missionId2); - string missionDefinitionsUrl = "/missions/definitions?pageSize=50"; var missionDefinitionsResponse = await _client.GetAsync(missionDefinitionsUrl); var missionDefinitions = await missionDefinitionsResponse.Content.ReadFromJsonAsync>(_serializerOptions); diff --git a/backend/api.test/Database/Models.cs b/backend/api.test/Database/Models.cs index 1cbe80535..7aeadd9fa 100644 --- a/backend/api.test/Database/Models.cs +++ b/backend/api.test/Database/Models.cs @@ -3,7 +3,7 @@ using Api.Services.Models; using Xunit; -namespace Api.Test.Services +namespace Api.Test.Database { public class TestPose { @@ -71,62 +71,5 @@ public void TestNegativaRotation() 3.0 ); } - - [Fact] - public void AssertCoordinateConversion() - { - var pose = new Pose( - new Position(25.041F, 23.682F, 0), - new Orientation(0, 0, 0.8907533F, 0.4544871F) - ); - var predefinedPosition = pose.Position; - var predefinedOrientation = pose.Orientation; - var echoPose = ConvertPredefinedPoseToEchoPose( - predefinedPosition, - predefinedOrientation - ); - - var flotillaPose = new Pose(echoPose.Position, echoPose.Orientation.Angle); - Assert.Equal(predefinedOrientation, flotillaPose.Orientation); - } - - private static EchoPose ConvertPredefinedPoseToEchoPose( - Position position, - Orientation orientation - ) - { - var enuPosition = new EnuPosition(position.X, position.Y, position.Z); - var axisAngle = ConvertOrientation(orientation); - return new EchoPose(enuPosition, axisAngle); - } - - private static AxisAngle ConvertOrientation(Orientation orientation) - // This is the method used to convert predefined poses to the Angle-Axis representation used by Echo - { - float qw = orientation.W; - float angle = -2 * MathF.Acos(qw); - if (orientation.Z >= 0) - angle = 2 * MathF.Acos(qw); - - angle = (450 * MathF.PI / 180) - angle; - - angle %= 2F * MathF.PI; - - if (angle < 0) angle += 2F * MathF.PI; - - return new AxisAngle(new EnuPosition(0, 0, 1), angle); - } - - public class AxisAngle(EnuPosition axis, float angle) - { - public EnuPosition Axis = axis; - public float Angle = angle; - } - - public class EchoPose(EnuPosition position, AxisAngle orientation) - { - public EnuPosition Position = position; - public AxisAngle Orientation = orientation; - } } } diff --git a/backend/api.test/EventHandlers/TestMissionEventHandler.cs b/backend/api.test/EventHandlers/TestMissionEventHandler.cs index dc3dd5def..ef7f97596 100644 --- a/backend/api.test/EventHandlers/TestMissionEventHandler.cs +++ b/backend/api.test/EventHandlers/TestMissionEventHandler.cs @@ -67,10 +67,9 @@ public TestMissionEventHandler(DatabaseFixture fixture) _missionRunService = new MissionRunService(context, signalRService, missionLogger, accessRoleService, userInfoService); - var echoServiceMock = new MockEchoService(); + var missionLoader = new MockMissionLoader(); var stidServiceMock = new MockStidService(context); - var sourceService = new SourceService(context, echoServiceMock, sourceServiceLogger); - var missionDefinitionService = new MissionDefinitionService(context, echoServiceMock, sourceService, signalRService, accessRoleService, missionDefinitionServiceLogger, _missionRunService); + var missionDefinitionService = new MissionDefinitionService(context, missionLoader, signalRService, accessRoleService, missionDefinitionServiceLogger, _missionRunService); var robotModelService = new RobotModelService(context); var taskDurationServiceMock = new MockTaskDurationService(); var isarServiceMock = new MockIsarService(); diff --git a/backend/api.test/Mocks/EchoServiceMock.cs b/backend/api.test/Mocks/EchoServiceMock.cs deleted file mode 100644 index 480707196..000000000 --- a/backend/api.test/Mocks/EchoServiceMock.cs +++ /dev/null @@ -1,73 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using Api.Controllers.Models; -using Api.Database.Models; -using Api.Services; -namespace Api.Test.Mocks -{ - public class MockEchoService : IEchoService - { - private readonly List _mockEchoPlantInfo = [ - new EchoPlantInfo - { - PlantCode = "testInstallation", - ProjectDescription = "testInstallation" - }, - new EchoPlantInfo - { - PlantCode = "JSV", - ProjectDescription = "JSVtestInstallation" - } - ]; - - public CondensedEchoMissionDefinition MockMissionDefinition = - new() - { - EchoMissionId = 1, - Name = "test" - }; - - public async Task> GetAvailableMissions(string? installationCode) - { - await Task.Run(() => Thread.Sleep(1)); - return new List(new[] { MockMissionDefinition }); - } - - public async Task GetMissionById(int missionId) - { - await Task.Run(() => Thread.Sleep(1)); - - var mockEchoMission = new EchoMission - { - Id = missionId, - Name = "test", - InstallationCode = "testInstallation", - URL = new Uri("https://testurl.com"), - Tags = new List{new() { - Id = 1, - TagId = "testTag", - Pose = new Pose(), - Inspections = new List{new() { - InspectionType = InspectionType.Image, - InspectionPoint = new Position{X=1, Y=1, Z=1} - }} - }} - }; - - return mockEchoMission; - } - - public async Task> GetEchoPlantInfos() - { - await Task.Run(() => Thread.Sleep(1)); - return _mockEchoPlantInfo; - } - - public Task GetMissionByPath(string relativePath) - { - throw new NotImplementedException(); - } - } -} diff --git a/backend/api.test/Mocks/MissionLoaderMock.cs b/backend/api.test/Mocks/MissionLoaderMock.cs new file mode 100644 index 000000000..b3937f1a4 --- /dev/null +++ b/backend/api.test/Mocks/MissionLoaderMock.cs @@ -0,0 +1,88 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Api.Controllers.Models; +using Api.Database.Models; +using Api.Services.MissionLoaders; +namespace Api.Test.Mocks +{ + public class MockMissionLoader() : IMissionLoader + { + private readonly List _mockPlantInfo = [ + new PlantInfo + { + PlantCode = "testInstallation", + ProjectDescription = "testInstallation" + }, + new PlantInfo + { + PlantCode = "JSV", + ProjectDescription = "JSVtestInstallation" + } + ]; + + private readonly List _mockMissionTasks = [ + new MissionTask( + inspections: [], + taskOrder: 0, + tagId: "1", + tagLink: new Uri("https://testurl.com"), + poseId: 1, + robotPose: new Pose + { + Position = new Position { X = 0, Y = 0, Z = 0 }, + Orientation = new Orientation { X = 0, Y = 0, Z = 0, W = 1 } + } + ), + new MissionTask( + inspections: [], + taskOrder: 0, + tagId: "2", + tagLink: new Uri("https://testurl.com"), + poseId: 1, + robotPose: new Pose + { + Position = new Position { X = 0, Y = 0, Z = 0 }, + Orientation = new Orientation { X = 0, Y = 0, Z = 0, W = 1 } + } + ), + ]; + + private readonly MissionDefinition _mockMissionDefinition = new() + { + Area = new Area(), + Comment = "", + Id = "", + InstallationCode = "TTT", + IsDeprecated = false, + Name = "test", + Source = new Source { Id = "", SourceId = "" } + }; + + public async Task GetMissionById(string sourceMissionId) + { + await Task.Run(() => Thread.Sleep(1)); + return _mockMissionDefinition; + } + + public async Task> GetAvailableMissions(string? installationCode) + { + await Task.Run(() => Thread.Sleep(1)); + return new List([_mockMissionDefinition]).AsQueryable(); + } + + public async Task> GetTasksForMission(string sourceMissionId) + { + await Task.Run(() => Thread.Sleep(1)); + return _mockMissionTasks; + } + + public async Task> GetPlantInfos() + { + await Task.Run(() => Thread.Sleep(1)); + return _mockPlantInfo; + } + } +} diff --git a/backend/api.test/TestWebApplicationFactory.cs b/backend/api.test/TestWebApplicationFactory.cs index b5eb2730d..4b6861ebf 100644 --- a/backend/api.test/TestWebApplicationFactory.cs +++ b/backend/api.test/TestWebApplicationFactory.cs @@ -1,5 +1,6 @@ using System.IO; using Api.Services; +using Api.Services.MissionLoaders; using Api.Test.Mocks; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Hosting; @@ -32,10 +33,10 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) services.AddScoped(); services.AddScoped(); services.AddSingleton(); - services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddAuthorizationBuilder().AddFallbackPolicy( TestAuthHandler.AuthenticationScheme, policy => policy.RequireAuthenticatedUser() ); diff --git a/backend/api/Configurations/CustomServiceConfigurations.cs b/backend/api/Configurations/CustomServiceConfigurations.cs index 551c0248c..bbf848f0d 100644 --- a/backend/api/Configurations/CustomServiceConfigurations.cs +++ b/backend/api/Configurations/CustomServiceConfigurations.cs @@ -1,5 +1,6 @@ using System.Reflection; using Api.Database.Context; +using Api.Services.MissionLoaders; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.OpenApi.Models; @@ -124,5 +125,33 @@ IConfiguration configuration return services; } + + public static IServiceCollection ConfigureMissionLoader( + this IServiceCollection services, + IConfiguration configuration + ) + { + string? missionLoaderFileName = configuration["MissionLoader:FileName"]; + Console.WriteLine(missionLoaderFileName); + if (missionLoaderFileName == null) return services; + + try + { + var loaderType = Type.GetType(missionLoaderFileName); + if (loaderType != null && typeof(IMissionLoader).IsAssignableFrom(loaderType)) + { + services.AddScoped(typeof(IMissionLoader), loaderType); + } + else + { + throw new InvalidOperationException("The specified class does not implement IMissionLoader or could not be found."); + } + } + catch (Exception) + { + throw; + } + return services; + } } } diff --git a/backend/api/Controllers/AreaController.cs b/backend/api/Controllers/AreaController.cs index 837bb516e..4372f068b 100644 --- a/backend/api/Controllers/AreaController.cs +++ b/backend/api/Controllers/AreaController.cs @@ -288,12 +288,12 @@ public async Task>> GetAreaByDeckId([FromRoute] [HttpGet] [Authorize(Roles = Role.Any)] [Route("{id}/mission-definitions")] - [ProducesResponseType(typeof(CondensedMissionDefinitionResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(MissionDefinitionResponse), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task>> GetMissionDefinitionsInArea([FromRoute] string id) + public async Task>> GetMissionDefinitionsInArea([FromRoute] string id) { try { @@ -302,7 +302,7 @@ public async Task>> GetMi return NotFound($"Could not find area with id {id}"); var missionDefinitions = await missionDefinitionService.ReadByAreaId(area.Id); - var missionDefinitionResponses = missionDefinitions.FindAll(m => !m.IsDeprecated).Select(m => new CondensedMissionDefinitionResponse(m)); + var missionDefinitionResponses = missionDefinitions.FindAll(m => !m.IsDeprecated).Select(m => new MissionDefinitionResponse(m)); return Ok(missionDefinitionResponses); } catch (Exception e) diff --git a/backend/api/Controllers/DeckController.cs b/backend/api/Controllers/DeckController.cs index cdfa550da..a6e35f9ad 100644 --- a/backend/api/Controllers/DeckController.cs +++ b/backend/api/Controllers/DeckController.cs @@ -107,22 +107,22 @@ public async Task> GetDeckById([FromRoute] string id) /// [HttpGet] [Authorize(Roles = Role.Any)] - [Route("{id}/mission-definitions")] - [ProducesResponseType(typeof(CondensedMissionDefinitionResponse), StatusCodes.Status200OK)] + [Route("{deckId}/mission-definitions")] + [ProducesResponseType(typeof(MissionDefinitionResponse), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task>> GetMissionDefinitionsInDeck([FromRoute] string id) + public async Task>> GetMissionDefinitionsInDeck([FromRoute] string deckId) { try { - var deck = await deckService.ReadById(id, readOnly: true); + var deck = await deckService.ReadById(deckId, readOnly: true); if (deck == null) - return NotFound($"Could not find deck with id {id}"); + return NotFound($"Could not find deck with id {deckId}"); var missionDefinitions = await missionDefinitionService.ReadByDeckId(deck.Id); - var missionDefinitionResponses = missionDefinitions.FindAll(m => !m.IsDeprecated).Select(m => new CondensedMissionDefinitionResponse(m)); + var missionDefinitionResponses = missionDefinitions.FindAll(m => !m.IsDeprecated).Select(m => new MissionDefinitionResponse(m)); return Ok(missionDefinitionResponses); } catch (Exception e) diff --git a/backend/api/Controllers/MissionDefinitionController.cs b/backend/api/Controllers/MissionDefinitionController.cs index fc46d30d2..5577800d9 100644 --- a/backend/api/Controllers/MissionDefinitionController.cs +++ b/backend/api/Controllers/MissionDefinitionController.cs @@ -19,59 +19,12 @@ public class MissionDefinitionController(ILogger lo /// [HttpGet("")] [Authorize(Roles = Role.Any)] - [ProducesResponseType(typeof(IList), StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task>> GetMissionDefinitions( - [FromQuery] MissionDefinitionQueryStringParameters parameters - ) - { - PagedList missionDefinitions; - try - { - missionDefinitions = await missionDefinitionService.ReadAll(parameters); - } - catch (InvalidDataException e) - { - logger.LogError(e, "{ErrorMessage}", e.Message); - return BadRequest(e.Message); - } - - var metadata = new - { - missionDefinitions.TotalCount, - missionDefinitions.PageSize, - missionDefinitions.CurrentPage, - missionDefinitions.TotalPages, - missionDefinitions.HasNext, - missionDefinitions.HasPrevious - }; - - Response.Headers.Append( - QueryStringParameters.PaginationHeader, - JsonSerializer.Serialize(metadata) - ); - - var missionDefinitionResponses = missionDefinitions.Select(m => new CondensedMissionDefinitionResponse(m)); - return Ok(missionDefinitionResponses); - } - - /// - /// List all condensed mission definitions in the Flotilla database - /// - /// - /// This query gets all condensed mission definitions - /// - [HttpGet("condensed")] - [Authorize(Roles = Role.Any)] [ProducesResponseType(typeof(IList), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task>> GetCondensedMissionDefinitions( + public async Task>> GetMissionDefinitions( [FromQuery] MissionDefinitionQueryStringParameters parameters ) { @@ -82,7 +35,7 @@ [FromQuery] MissionDefinitionQueryStringParameters parameters } catch (InvalidDataException e) { - logger.LogError(e.Message); + logger.LogError(e, "{ErrorMessage}", e.Message); return BadRequest(e.Message); } @@ -101,32 +54,10 @@ [FromQuery] MissionDefinitionQueryStringParameters parameters JsonSerializer.Serialize(metadata) ); - var missionDefinitionResponses = missionDefinitions.Select(m => new CondensedMissionDefinitionResponse(m)); + var missionDefinitionResponses = missionDefinitions.Select(m => new MissionDefinitionResponse(m)); return Ok(missionDefinitionResponses); } - /// - /// Lookup mission definition by specified id. - /// - [HttpGet] - [Authorize(Roles = Role.Any)] - [Route("{id}/condensed")] - [ProducesResponseType(typeof(CondensedMissionDefinitionResponse), StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task> GetCondensedMissionDefinitionById([FromRoute] string id) - { - var missionDefinition = await missionDefinitionService.ReadById(id); - if (missionDefinition == null) - { - return NotFound($"Could not find mission definition with id {id}"); - } - var missionDefinitionResponse = new CondensedMissionDefinitionResponse(missionDefinition); - return Ok(missionDefinitionResponse); - } - /// /// Lookup mission definition by specified id. /// @@ -138,14 +69,14 @@ public async Task> GetCondensed [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task> GetMissionDefinitionById([FromRoute] string id) + public async Task> GetMissionDefinitionWithTasksById([FromRoute] string id) { var missionDefinition = await missionDefinitionService.ReadById(id); if (missionDefinition == null) { return NotFound($"Could not find mission definition with id {id}"); } - var missionDefinitionResponse = new MissionDefinitionResponse(missionDefinitionService, missionDefinition); + var missionDefinitionResponse = new MissionDefinitionWithTasksResponse(missionDefinitionService, missionDefinition); return Ok(missionDefinitionResponse); } @@ -180,13 +111,13 @@ public async Task> GetNextMissionRun([FromRoute] string [HttpPut] [Authorize(Roles = Role.Admin)] [Route("{id}")] - [ProducesResponseType(typeof(CondensedMissionDefinitionResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(MissionDefinitionResponse), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task> UpdateMissionDefinitionById( + public async Task> UpdateMissionDefinitionById( [FromRoute] string id, [FromBody] UpdateMissionDefinitionQuery missionDefinitionQuery ) @@ -214,7 +145,7 @@ [FromBody] UpdateMissionDefinitionQuery missionDefinitionQuery missionDefinition.InspectionFrequency = missionDefinitionQuery.InspectionFrequency; var newMissionDefinition = await missionDefinitionService.Update(missionDefinition); - return new CondensedMissionDefinitionResponse(newMissionDefinition); + return new MissionDefinitionResponse(newMissionDefinition); } /// @@ -226,13 +157,13 @@ [FromBody] UpdateMissionDefinitionQuery missionDefinitionQuery [HttpPut] [Authorize(Roles = Role.Admin)] [Route("{id}/is-deprecated")] - [ProducesResponseType(typeof(CondensedMissionDefinitionResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(MissionDefinitionResponse), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task> UpdateMissionDefinitionIsDeprecatedById( + public async Task> UpdateMissionDefinitionIsDeprecatedById( [FromRoute] string id, [FromBody] UpdateMissionDefinitionIsDeprecatedQuery missionDefinitionIsDeprecatedQuery ) @@ -252,7 +183,7 @@ [FromBody] UpdateMissionDefinitionIsDeprecatedQuery missionDefinitionIsDeprecate missionDefinition.IsDeprecated = missionDefinitionIsDeprecatedQuery.IsDeprecated; var newMissionDefinition = await missionDefinitionService.Update(missionDefinition); - return new CondensedMissionDefinitionResponse(newMissionDefinition); + return new MissionDefinitionResponse(newMissionDefinition); } /// @@ -266,14 +197,14 @@ [FromBody] UpdateMissionDefinitionIsDeprecatedQuery missionDefinitionIsDeprecate [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task> DeleteMissionDefinition([FromRoute] string id) + public async Task> DeleteMissionDefinitionWithTasks([FromRoute] string id) { var missionDefinition = await missionDefinitionService.Delete(id); if (missionDefinition is null) { return NotFound($"Mission definition with id {id} not found"); } - var missionDefinitionResponse = new MissionDefinitionResponse(missionDefinitionService, missionDefinition); + var missionDefinitionResponse = new MissionDefinitionWithTasksResponse(missionDefinitionService, missionDefinition); return Ok(missionDefinitionResponse); } } diff --git a/backend/api/Controllers/EchoController.cs b/backend/api/Controllers/MissionLoaderController.cs similarity index 63% rename from backend/api/Controllers/EchoController.cs rename to backend/api/Controllers/MissionLoaderController.cs index 564862831..88cc709ea 100644 --- a/backend/api/Controllers/EchoController.cs +++ b/backend/api/Controllers/MissionLoaderController.cs @@ -1,123 +1,134 @@ using System.Globalization; using System.Text.Json; using Api.Controllers.Models; +using Api.Database.Models; using Api.Services; +using Api.Services.MissionLoaders; using Api.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace Api.Controllers { [ApiController] - [Route("echo")] + [Route("mission-loader")] [Authorize(Roles = Role.Any)] - public class EchoController(ILogger logger, IEchoService echoService, IRobotService robotService) : ControllerBase + public class MissionLoaderController(ILogger logger, IMissionLoader missionLoader, IRobotService robotService) : ControllerBase { /// - /// List all available Echo missions for the installation + /// List all available missions for the installation /// /// - /// These missions are created in the Echo mission planner + /// These missions are fetched based on your mission loader /// [HttpGet] [Route("available-missions/{installationCode}")] - [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(IList), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] [ProducesResponseType(StatusCodes.Status502BadGateway)] - public async Task>> GetAvailableEchoMissions([FromRoute] string? installationCode) + public async Task>> GetAvailableMissions( + [FromRoute] string? installationCode) { + IQueryable missionDefinitions; try { - var missions = await echoService.GetAvailableMissions(installationCode); - return Ok(missions); + missionDefinitions = await missionLoader.GetAvailableMissions(installationCode); + } + catch (InvalidDataException e) + { + logger.LogError(e, "{ErrorMessage}", e.Message); + return BadRequest(e.Message); } catch (HttpRequestException e) { - logger.LogError(e, "Error retrieving missions from Echo"); + logger.LogError(e, "Error retrieving missions from Mission Loader"); return new StatusCodeResult(StatusCodes.Status502BadGateway); } catch (JsonException e) { - logger.LogError(e, "Error retrieving missions from Echo"); + logger.LogError(e, "Error retrieving missions from MissionLoader"); return new StatusCodeResult(StatusCodes.Status500InternalServerError); } + + var missionDefinitionResponses = missionDefinitions.Select(m => new MissionDefinitionResponse(m)).ToList(); + return Ok(missionDefinitionResponses); } /// - /// Lookup Echo mission by Id + /// Lookup mission by Id /// /// - /// This mission is created in the Echo mission planner + /// This mission is loaded from the mission loader /// [HttpGet] [Route("missions/{missionId}")] - [ProducesResponseType(typeof(EchoMission), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(MissionDefinitionResponse), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] [ProducesResponseType(StatusCodes.Status502BadGateway)] - public async Task> GetEchoMission([FromRoute] int missionId) + public async Task> GetMissionDefinition([FromRoute] string missionId) { try { - var mission = await echoService.GetMissionById(missionId); + var mission = await missionLoader.GetMissionById(missionId); return Ok(mission); } catch (HttpRequestException e) { if (e.StatusCode.HasValue && (int)e.StatusCode.Value == 404) { - logger.LogWarning("Could not find echo mission with id={id}", missionId); - return NotFound("Echo mission not found"); + logger.LogWarning("Could not find mission with id={id}", missionId); + return NotFound("Mission not found"); } - logger.LogError(e, "Error getting mission from Echo"); + logger.LogError(e, "Error getting mission from mission loader"); return new StatusCodeResult(StatusCodes.Status502BadGateway); } catch (JsonException e) { - logger.LogError(e, "Error deserializing mission from Echo"); + logger.LogError(e, "Error deserializing mission from mission loader"); return new StatusCodeResult(StatusCodes.Status500InternalServerError); } catch (InvalidDataException e) { string message = - "EchoMission invalid: One or more tags are missing associated robot poses."; + "Mission invalid: One or more tags are missing associated robot poses."; logger.LogError(e, message); return StatusCode(StatusCodes.Status502BadGateway, message); } } /// - /// Get selected information on all the plants in Echo + /// Get selected information on all the plants /// [HttpGet] [Route("plants")] - [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] [ProducesResponseType(StatusCodes.Status502BadGateway)] - public async Task> GetEchoPlantInfos() + public async Task> GetPlantInfos() { try { - var echoPlantInfos = await echoService.GetEchoPlantInfos(); - return Ok(echoPlantInfos); + var plantInfos = await missionLoader.GetPlantInfos(); + return Ok(plantInfos); } catch (HttpRequestException e) { - logger.LogError(e, "Error getting plant info from Echo"); + logger.LogError(e, "Error getting plant info"); return new StatusCodeResult(StatusCodes.Status502BadGateway); } catch (JsonException e) { - logger.LogError(e, "Error deserializing plant info response from Echo"); + logger.LogError(e, "Error deserializing plant info response"); return new StatusCodeResult(StatusCodes.Status500InternalServerError); } } @@ -131,12 +142,12 @@ public async Task> GetEchoPlantInfos() [HttpGet] [Authorize(Roles = Role.User)] [Route("active-plants")] - [ProducesResponseType(typeof(IList), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(IList), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task>> GetActivePlants() + public async Task>> GetActivePlants() { var plants = await robotService.ReadAllActivePlants(); @@ -150,19 +161,19 @@ public async Task>> GetActivePlants() try { - var echoPlantInfos = await echoService.GetEchoPlantInfos(); + var plantInfos = await missionLoader.GetPlantInfos(); - echoPlantInfos = echoPlantInfos.Where(p => plants.Contains(p.PlantCode.ToLower(CultureInfo.CurrentCulture))).ToList(); - return Ok(echoPlantInfos); + plantInfos = plantInfos.Where(p => plants.Contains(p.PlantCode.ToLower(CultureInfo.CurrentCulture))).ToList(); + return Ok(plantInfos); } catch (HttpRequestException e) { - logger.LogError(e, "Error getting plant info from Echo"); + logger.LogError(e, "Error getting plant info"); return new StatusCodeResult(StatusCodes.Status502BadGateway); } catch (JsonException e) { - logger.LogError(e, "Error deserializing plant info response from Echo"); + logger.LogError(e, "Error deserializing plant info response"); return new StatusCodeResult(StatusCodes.Status500InternalServerError); } } diff --git a/backend/api/Controllers/MissionSchedulingController.cs b/backend/api/Controllers/MissionSchedulingController.cs index cdc97d7b0..d000626f6 100644 --- a/backend/api/Controllers/MissionSchedulingController.cs +++ b/backend/api/Controllers/MissionSchedulingController.cs @@ -1,7 +1,9 @@ -using System.Text.Json; +using System.Globalization; +using System.Text.Json; using Api.Controllers.Models; using Api.Database.Models; using Api.Services; +using Api.Services.MissionLoaders; using Api.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -15,7 +17,7 @@ public class MissionSchedulingController( IMissionDefinitionService missionDefinitionService, IMissionRunService missionRunService, IInstallationService installationService, - IEchoService echoService, + IMissionLoader missionLoader, ILogger logger, IMapService mapService, IStidService stidService, @@ -136,7 +138,7 @@ [FromBody] ScheduleMissionQuery scheduledMissionQuery catch (InstallationNotFoundException e) { return NotFound(e.Message); } catch (RobotNotInSameInstallationAsMissionException e) { return Conflict(e.Message); } - var missionTasks = await missionDefinitionService.GetTasksFromSource(missionDefinition.Source, missionDefinition.InstallationCode); + var missionTasks = await missionLoader.GetTasksForMission(missionDefinition.Source.SourceId); if (missionTasks == null) return NotFound("No mission tasks were found for the requested mission"); var missionRun = new MissionRun @@ -177,10 +179,10 @@ [FromBody] ScheduleMissionQuery scheduledMissionQuery } /// - /// Schedule a new echo mission + /// Schedule a mission based on mission loader /// /// - /// This query schedules a new echo mission and adds it to the database + /// This query schedules a new mission and adds it to the database /// [HttpPost] [Authorize(Roles = Role.User)] @@ -199,59 +201,58 @@ [FromBody] ScheduledMissionQuery scheduledMissionQuery try { robot = await robotService.GetRobotWithPreCheck(scheduledMissionQuery.RobotId); } catch (Exception e) when (e is RobotNotFoundException) { return NotFound(e.Message); } catch (Exception e) when (e is RobotPreCheckFailedException) { return BadRequest(e.Message); } - - EchoMission? echoMission; + string missionId = scheduledMissionQuery.MissionId.ToString(CultureInfo.CurrentCulture); + MissionDefinition? missionDefinition; try { - echoMission = await echoService.GetMissionById(scheduledMissionQuery.EchoMissionId); + missionDefinition = await missionLoader.GetMissionById(missionId); + if (missionDefinition == null) + { + return NotFound("Mission not found"); + } } catch (HttpRequestException e) { if (e.StatusCode.HasValue && (int)e.StatusCode.Value == 404) { logger.LogWarning( - "Could not find echo mission with id={Id}", - scheduledMissionQuery.EchoMissionId + "Could not find mission with id={Id}", + missionId ); - return NotFound("Echo mission not found"); + return NotFound("Mission not found"); } - logger.LogError(e, "Error getting mission from Echo"); + logger.LogError(e, "Error getting mission from mission loader"); return StatusCode(StatusCodes.Status502BadGateway, $"{e.Message}"); } catch (JsonException e) { - const string Message = "Error deserializing mission from Echo"; + const string Message = "Error deserializing mission"; logger.LogError(e, "{Message}", Message); return StatusCode(StatusCodes.Status500InternalServerError, Message); } catch (InvalidDataException e) { const string Message = - "Can not schedule mission because EchoMission is invalid. One or more tasks does not contain a robot pose"; + "Can not schedule mission because Mission is invalid. One or more tasks does not contain a robot pose"; logger.LogError(e, "Message: {errorMessage}", Message); return StatusCode(StatusCodes.Status502BadGateway, Message); } - var missionTasks = echoMission.Tags - .SelectMany( - t => - { - return t.Inspections.Select(i => new MissionTask(t)).ToList(); - } - ) - .ToList(); + + var missionTasks = await missionLoader.GetTasksForMission(missionId); List missionAreas; - missionAreas = echoMission.Tags - .Select(t => stidService.GetTagArea(t.TagId, scheduledMissionQuery.InstallationCode).Result) + missionAreas = missionTasks + .Where(t => t.TagId != null) + .Select(t => stidService.GetTagArea(t.TagId!, scheduledMissionQuery.InstallationCode).Result) .ToList(); var missionDeckNames = missionAreas.Where(a => a != null).Select(a => a!.Deck.Name).Distinct().ToList(); if (missionDeckNames.Count > 1) { string joinedMissionDeckNames = string.Join(", ", [.. missionDeckNames]); - logger.LogWarning($"Mission {echoMission.Name} has tags on more than one deck. The decks are: {joinedMissionDeckNames}."); + logger.LogWarning($"Mission {missionDefinition.Name} has tags on more than one deck. The decks are: {joinedMissionDeckNames}."); } Area? area = null; @@ -259,18 +260,17 @@ [FromBody] ScheduledMissionQuery scheduledMissionQuery if (area == null) { - return NotFound($"No area found for echo mission '{echoMission.Name}'."); + return NotFound($"No area found for mission '{missionDefinition.Name}'."); } - var source = await sourceService.CheckForExistingEchoSource(scheduledMissionQuery.EchoMissionId); + var source = await sourceService.CheckForExistingSource(scheduledMissionQuery.MissionId); MissionDefinition? existingMissionDefinition = null; if (source == null) { source = await sourceService.Create( new Source { - SourceId = $"{echoMission.Id}", - Type = MissionSourceType.Echo + SourceId = $"{missionDefinition.Id}", } ); } @@ -287,7 +287,7 @@ [FromBody] ScheduledMissionQuery scheduledMissionQuery { Id = Guid.NewGuid().ToString(), Source = source, - Name = echoMission.Name, + Name = missionDefinition.Name, InspectionFrequency = scheduledMissionQuery.InspectionFrequency, InstallationCode = scheduledMissionQuery.InstallationCode, Area = area @@ -295,7 +295,7 @@ [FromBody] ScheduledMissionQuery scheduledMissionQuery var missionRun = new MissionRun { - Name = echoMission.Name, + Name = missionDefinition.Name, Robot = robot, MissionId = scheduledMissionDefinition.Id, Status = MissionStatus.Pending, @@ -381,7 +381,7 @@ [FromBody] CustomMissionQuery customMissionQuery throw new AreaNotFoundException($"No area with name {customMissionQuery.AreaName} in installation {customMissionQuery.InstallationCode} was found"); } - var source = await sourceService.CheckForExistingCustomSource(missionTasks); + var source = await sourceService.CheckForExistingSourceFromTasks(missionTasks); MissionDefinition? existingMissionDefinition = null; if (source == null) diff --git a/backend/api/Controllers/Models/CondensedMissionDefinition.cs b/backend/api/Controllers/Models/CondensedMissionDefinition.cs deleted file mode 100644 index d624c7920..000000000 --- a/backend/api/Controllers/Models/CondensedMissionDefinition.cs +++ /dev/null @@ -1,12 +0,0 @@ -# nullable disable -namespace Api.Controllers.Models -{ - public class CondensedEchoMissionDefinition - { - public int EchoMissionId { get; set; } - - public string Name { get; set; } - - public string InstallationCode { get; set; } - } -} diff --git a/backend/api/Controllers/Models/MissionDefinitionQueryStringParameters.cs b/backend/api/Controllers/Models/MissionDefinitionQueryStringParameters.cs index 364af5ac3..0c8391fba 100644 --- a/backend/api/Controllers/Models/MissionDefinitionQueryStringParameters.cs +++ b/backend/api/Controllers/Models/MissionDefinitionQueryStringParameters.cs @@ -1,6 +1,4 @@ -using Api.Database.Models; - -namespace Api.Controllers.Models +namespace Api.Controllers.Models { public class MissionDefinitionQueryStringParameters : QueryStringParameters { @@ -26,8 +24,8 @@ public MissionDefinitionQueryStringParameters() public string? NameSearch { get; set; } /// - /// The search parameter for the mission source type + /// The search parameter for the mission source id /// - public MissionSourceType? SourceType { get; set; } + public string? SourceId { get; set; } } } diff --git a/backend/api/Controllers/Models/MissionDefinitionResponse.cs b/backend/api/Controllers/Models/MissionDefinitionResponse.cs index 26456fda0..d403173aa 100644 --- a/backend/api/Controllers/Models/MissionDefinitionResponse.cs +++ b/backend/api/Controllers/Models/MissionDefinitionResponse.cs @@ -5,7 +5,7 @@ namespace Api.Controllers.Models { - public class CondensedMissionDefinitionResponse + public class MissionDefinitionResponse { [JsonPropertyName("id")] public string Id { get; set; } = string.Empty; @@ -31,13 +31,13 @@ public class CondensedMissionDefinitionResponse [JsonPropertyName("isDeprecated")] public bool IsDeprecated { get; set; } - [JsonPropertyName("sourceType")] - public MissionSourceType SourceType { get; set; } + [JsonPropertyName("sourceId")] + public string SourceId { get; set; } = string.Empty; [JsonConstructor] - public CondensedMissionDefinitionResponse() { } + public MissionDefinitionResponse() { } - public CondensedMissionDefinitionResponse(MissionDefinition missionDefinition) + public MissionDefinitionResponse(MissionDefinition missionDefinition) { Id = missionDefinition.Id; Name = missionDefinition.Name; @@ -47,17 +47,17 @@ public CondensedMissionDefinitionResponse(MissionDefinition missionDefinition) Area = missionDefinition.Area != null ? new AreaResponse(missionDefinition.Area) : null; LastSuccessfulRun = missionDefinition.LastSuccessfulRun; IsDeprecated = missionDefinition.IsDeprecated; - SourceType = missionDefinition.Source.Type; + SourceId = missionDefinition.Source.SourceId; } } - public class MissionDefinitionResponse(IMissionDefinitionService service, MissionDefinition missionDefinition) + public class MissionDefinitionWithTasksResponse(IMissionDefinitionService service, MissionDefinition missionDefinition) { [JsonPropertyName("id")] public string Id { get; } = missionDefinition.Id; [JsonPropertyName("tasks")] - public List Tasks { get; } = service.GetTasksFromSource(missionDefinition.Source, missionDefinition.InstallationCode).Result!; + public List Tasks { get; } = service.GetTasksFromSource(missionDefinition.Source).Result!; [JsonPropertyName("name")] public string Name { get; } = missionDefinition.Name; @@ -79,8 +79,5 @@ public class MissionDefinitionResponse(IMissionDefinitionService service, Missio [JsonPropertyName("isDeprecated")] public bool IsDeprecated { get; } = missionDefinition.IsDeprecated; - - [JsonPropertyName("sourceType")] - public MissionSourceType SourceType { get; } = missionDefinition.Source.Type; } } diff --git a/backend/api/Controllers/Models/EchoPlantInfo.cs b/backend/api/Controllers/Models/PlantInfo.cs similarity index 84% rename from backend/api/Controllers/Models/EchoPlantInfo.cs rename to backend/api/Controllers/Models/PlantInfo.cs index 9e955004d..375b52a01 100644 --- a/backend/api/Controllers/Models/EchoPlantInfo.cs +++ b/backend/api/Controllers/Models/PlantInfo.cs @@ -1,7 +1,7 @@ #nullable disable namespace Api.Controllers.Models { - public class EchoPlantInfo + public class PlantInfo { public string PlantCode { get; set; } public string ProjectDescription { get; set; } diff --git a/backend/api/Controllers/Models/ScheduledMissionQuery.cs b/backend/api/Controllers/Models/ScheduledMissionQuery.cs index 7d0c4b09a..9c16fad30 100644 --- a/backend/api/Controllers/Models/ScheduledMissionQuery.cs +++ b/backend/api/Controllers/Models/ScheduledMissionQuery.cs @@ -3,7 +3,7 @@ public struct ScheduledMissionQuery { public string RobotId { get; set; } - public int EchoMissionId { get; set; } + public string MissionId { get; set; } public DateTime? DesiredStartTime { get; set; } public string InstallationCode { get; set; } public string? AreaName { get; set; } diff --git a/backend/api/Controllers/Models/SourceQueryStringParameters.cs b/backend/api/Controllers/Models/SourceQueryStringParameters.cs index e1adc502d..034e58cdb 100644 --- a/backend/api/Controllers/Models/SourceQueryStringParameters.cs +++ b/backend/api/Controllers/Models/SourceQueryStringParameters.cs @@ -1,18 +1,10 @@ -using Api.Database.Models; - -namespace Api.Controllers.Models +namespace Api.Controllers.Models { public class SourceQueryStringParameters : QueryStringParameters { - public SourceQueryStringParameters() - { - // Default order is mission source type - OrderBy = "MissionSourceType type"; - } - /// - /// Filter based on mission source type + /// The search parameter for the task string /// - public MissionSourceType? Type { get; set; } + public string? TaskNameSearch { get; set; } } } diff --git a/backend/api/Controllers/Models/SourceResponse.cs b/backend/api/Controllers/Models/SourceResponse.cs index a5f5efef2..d828781c3 100644 --- a/backend/api/Controllers/Models/SourceResponse.cs +++ b/backend/api/Controllers/Models/SourceResponse.cs @@ -6,8 +6,6 @@ public class SourceResponse(Source source, IList tasks) public string SourceId { get; } = source.SourceId; - public MissionSourceType Type { get; } = source.Type; - public IList Tasks = tasks; } } diff --git a/backend/api/Controllers/SourceController.cs b/backend/api/Controllers/SourceController.cs index 41ee51ebe..605c0e2be 100644 --- a/backend/api/Controllers/SourceController.cs +++ b/backend/api/Controllers/SourceController.cs @@ -1,8 +1,6 @@ -using System.Text.Json; -using Api.Controllers.Models; +using Api.Controllers.Models; using Api.Database.Models; using Api.Services; -using Api.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -28,14 +26,12 @@ ILogger logger [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task>> GetAllSources( - [FromQuery] SourceQueryStringParameters? parameters - ) + public async Task>> GetAllSources() { - PagedList sources; + List sources; try { - sources = await sourceService.ReadAll(parameters); + sources = await sourceService.ReadAll(); } catch (InvalidDataException e) { @@ -43,21 +39,6 @@ [FromQuery] SourceQueryStringParameters? parameters return BadRequest(e.Message); } - var metadata = new - { - sources.TotalCount, - sources.PageSize, - sources.CurrentPage, - sources.TotalPages, - sources.HasNext, - sources.HasPrevious - }; - - Response.Headers.Append( - QueryStringParameters.PaginationHeader, - JsonSerializer.Serialize(metadata) - ); - return Ok(sources); } @@ -66,34 +47,15 @@ [FromQuery] SourceQueryStringParameters? parameters /// [HttpGet] [Authorize(Roles = Role.Any)] - [Route("custom/{id}")] - [ProducesResponseType(typeof(SourceResponse), StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task> GetCustomSourceById([FromRoute] string id) - { - var source = await sourceService.ReadByIdWithTasks(id); - if (source == null) - return NotFound($"Could not find mission definition with id {id}"); - return Ok(source); - } - - /// - /// Lookup an echo source by specified id. - /// - [HttpGet] - [Authorize(Roles = Role.Any)] - [Route("echo/{id}/{installationCode}")] + [Route("{id}")] [ProducesResponseType(typeof(SourceResponse), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task> GetEchoSourceById([FromRoute] string id, [FromRoute] string installationCode) + public async Task> GetSourceById([FromRoute] string id) { - var source = await sourceService.ReadByIdAndInstallationWithTasks(id, installationCode); + var source = await sourceService.ReadById(id); if (source == null) return NotFound($"Could not find mission definition with id {id}"); return Ok(source); diff --git a/backend/api/Database/Context/InitDb.cs b/backend/api/Database/Context/InitDb.cs index af186105b..6c837dc85 100644 --- a/backend/api/Database/Context/InitDb.cs +++ b/backend/api/Database/Context/InitDb.cs @@ -1,5 +1,4 @@ -using Api.Controllers.Models; -using Api.Database.Models; +using Api.Database.Models; using TaskStatus = Api.Database.Models.TaskStatus; namespace Api.Database.Context { @@ -254,25 +253,24 @@ private static List GetSources() var source1 = new Source { SourceId = "986", - Type = MissionSourceType.Echo }; var source2 = new Source { SourceId = "990", - Type = MissionSourceType.Echo }; var source3 = new Source { SourceId = "991", - Type = MissionSourceType.Echo }; - return new List(new[] - { - source1, source2, source3 - }); + return new List( + [ + source1, + source2, + source3 + ]); } private static List GetRobots() @@ -316,10 +314,12 @@ private static List GetRobots() Pose = new Pose() }; - return new List(new[] - { - robot1, robot2, robot3 - }); + return new List( + [ + robot1, + robot2, + robot3 + ]); } private static List GetMissionDefinitions() @@ -399,153 +399,92 @@ private static List GetMissionDefinitions() LastSuccessfulRun = null }; - return new List(new[] - { - missionDefinition1, missionDefinition2, missionDefinition3, missionDefinition4, missionDefinition5, missionDefinition6 - }); + return new List( + [ + missionDefinition1, + missionDefinition2, + missionDefinition3, + missionDefinition4, + missionDefinition5, + missionDefinition6 + ]); } private static List GetMissionTasks() { + var inspections = new List { new() }; + var url = new Uri( + "https://stid.equinor.com/hua/tag?tagNo=ABCD" + ); var task1 = new MissionTask( - new EchoTag - { - Id = 2, - TagId = "ABCD", - PoseId = 2, - PlanOrder = 0, - Pose = new Pose(300.0f, 50.0f, 200.0f, 0.0f, 0.0f, 0.0f, 1.0f), - URL = new Uri( - "https://stid.equinor.com/hua/tag?tagNo=ABCD" - ), - Inspections = new List - { - new() - } - }) - { - Status = TaskStatus.Successful - }; + inspections: inspections, + robotPose: new Pose(300.0f, 50.0f, 200.0f, 0.0f, 0.0f, 0.0f, 1.0f), + taskOrder: 0, + tagLink: url, + tagId: "ABCD", + poseId: 2, + status: TaskStatus.Successful + ); var task2 = new MissionTask( - new EchoTag - { - Id = 3, - TagId = "ABCDE", - PoseId = 2, - PlanOrder = 0, - Pose = new Pose(300.0f, 50.0f, 200.0f, 0.0f, 0.0f, 0.0f, 1.0f), - URL = new Uri( - "https://stid.equinor.com/hua/tag?tagNo=ABCD" - ), - Inspections = new List - { - new() - } - }) - { - Status = TaskStatus.Failed - }; + inspections: inspections, + robotPose: new Pose(300.0f, 50.0f, 200.0f, 0.0f, 0.0f, 0.0f, 1.0f), + taskOrder: 0, + tagLink: url, + tagId: "ABCDE", + poseId: 2, + status: TaskStatus.Failed + ); var task3 = new MissionTask( - new EchoTag - { - Id = 4, - TagId = "ABCDEF", - PoseId = 2, - PlanOrder = 0, - Pose = new Pose(300.0f, 50.0f, 200.0f, 0.0f, 0.0f, 0.0f, 1.0f), - URL = new Uri( - "https://stid.equinor.com/hua/tag?tagNo=ABCD" - ), - Inspections = new List - { - new() - } - }) - { - Status = TaskStatus.PartiallySuccessful - }; + inspections: inspections, + robotPose: new Pose(300.0f, 50.0f, 200.0f, 0.0f, 0.0f, 0.0f, 1.0f), + taskOrder: 0, + tagLink: url, + tagId: "ABCDEF", + poseId: 2, + status: TaskStatus.PartiallySuccessful + ); var task4 = new MissionTask( - new EchoTag - { - Id = 5, - TagId = "ABCDEFG", - PoseId = 2, - PlanOrder = 0, - Pose = new Pose(300.0f, 50.0f, 200.0f, 0.0f, 0.0f, 0.0f, 1.0f), - URL = new Uri( - "https://stid.equinor.com/hua/tag?tagNo=ABCD" - ), - Inspections = new List - { - new() - } - }) - { - Status = TaskStatus.Cancelled - }; + inspections: inspections, + robotPose: new Pose(300.0f, 50.0f, 200.0f, 0.0f, 0.0f, 0.0f, 1.0f), + taskOrder: 0, + tagLink: url, + tagId: "ABCDEFG", + poseId: 2, + status: TaskStatus.Cancelled + ); var task5 = new MissionTask( - new EchoTag - { - Id = 6, - TagId = "ABCDEFGH", - PoseId = 2, - PlanOrder = 0, - Pose = new Pose(300.0f, 50.0f, 200.0f, 0.0f, 0.0f, 0.0f, 1.0f), - URL = new Uri( - "https://stid.equinor.com/hua/tag?tagNo=ABCD" - ), - Inspections = new List - { - new() - } - }) - { - Status = TaskStatus.Failed - }; + inspections: inspections, + robotPose: new Pose(300.0f, 50.0f, 200.0f, 0.0f, 0.0f, 0.0f, 1.0f), + taskOrder: 0, + tagLink: url, + tagId: "ABCDEFGH", + poseId: 2, + status: TaskStatus.Failed + ); var task6 = new MissionTask( - new EchoTag - { - Id = 7, - TagId = "ABCDEFGHI", - PoseId = 2, - PlanOrder = 0, - Pose = new Pose(300.0f, 50.0f, 200.0f, 0.0f, 0.0f, 0.0f, 1.0f), - URL = new Uri( - "https://stid.equinor.com/hua/tag?tagNo=ABCD" - ), - Inspections = new List - { - new() - } - }) - { - Status = TaskStatus.Failed - }; + inspections: inspections, + robotPose: new Pose(300.0f, 50.0f, 200.0f, 0.0f, 0.0f, 0.0f, 1.0f), + taskOrder: 0, + tagLink: url, + tagId: "ABCDEFGHI", + poseId: 2, + status: TaskStatus.Failed + ); var task7 = new MissionTask( - new EchoTag - { - Id = 8, - TagId = "ABCDEFGHIJ", - PoseId = 2, - PlanOrder = 0, - Pose = new Pose(300.0f, 50.0f, 200.0f, 0.0f, 0.0f, 0.0f, 1.0f), - URL = new Uri( - "https://stid.equinor.com/hua/tag?tagNo=ABCD" - ), - Inspections = new List - { - new() - } - }) - { - Status = TaskStatus.Failed - }; + inspections: inspections, + robotPose: new Pose(300.0f, 50.0f, 200.0f, 0.0f, 0.0f, 0.0f, 1.0f), + taskOrder: 0, + tagLink: url, + tagId: "ABCDEFGHIJ", + poseId: 2, + status: TaskStatus.Failed + ); return [ task1, @@ -596,7 +535,7 @@ private static List GetMissionRuns() MissionId = missionDefinitions[1].Id, Status = MissionStatus.Successful, DesiredStartTime = DateTime.UtcNow, - Tasks = new List(), + Tasks = [], Map = new MapMetadata() }; @@ -609,11 +548,11 @@ private static List GetMissionRuns() MissionId = missionDefinitions[1].Id, Status = MissionStatus.Failed, DesiredStartTime = DateTime.UtcNow, - Tasks = new List - { + Tasks = + [ tasks[0], tasks[1] - }, + ], Map = new MapMetadata() }; @@ -626,11 +565,11 @@ private static List GetMissionRuns() MissionId = missionDefinitions[1].Id, Status = MissionStatus.PartiallySuccessful, DesiredStartTime = DateTime.UtcNow, - Tasks = new List - { + Tasks = + [ tasks[0], tasks[2] - }, + ], Map = new MapMetadata() }; @@ -643,11 +582,11 @@ private static List GetMissionRuns() MissionId = missionDefinitions[1].Id, Status = MissionStatus.Cancelled, DesiredStartTime = DateTime.UtcNow, - Tasks = new List - { + Tasks = + [ tasks[0], tasks[3] - }, + ], Map = new MapMetadata() }; @@ -660,8 +599,8 @@ private static List GetMissionRuns() MissionId = missionDefinitions[1].Id, Status = MissionStatus.Failed, DesiredStartTime = DateTime.UtcNow, - Tasks = new List - { + Tasks = + [ tasks[0], tasks[1], tasks[2], @@ -669,7 +608,7 @@ private static List GetMissionRuns() tasks[4], tasks[5], tasks[6] - }, + ], Map = new MapMetadata() }; diff --git a/backend/api/Database/Models/Inspection.cs b/backend/api/Database/Models/Inspection.cs index 2bedba0d6..650a578ec 100644 --- a/backend/api/Database/Models/Inspection.cs +++ b/backend/api/Database/Models/Inspection.cs @@ -16,12 +16,19 @@ public Inspection() InspectionTarget = new Position(); } - public Inspection(EchoInspection echoInspection) + public Inspection( + InspectionType inspectionType, + float? videoDuration, + Position inspectionTarget, + InspectionStatus status = InspectionStatus.NotStarted, + AnalysisType? analysisType = null + ) { - InspectionType = echoInspection.InspectionType; - VideoDuration = echoInspection.TimeInSeconds; - Status = InspectionStatus.NotStarted; - InspectionTarget = echoInspection.InspectionPoint; + InspectionType = inspectionType; + VideoDuration = videoDuration; + InspectionTarget = inspectionTarget; + AnalysisType = analysisType; + Status = status; } public Inspection(CustomInspectionQuery inspectionQuery) diff --git a/backend/api/Database/Models/MissionTask.cs b/backend/api/Database/Models/MissionTask.cs index f7e486ce2..eef0f8d82 100644 --- a/backend/api/Database/Models/MissionTask.cs +++ b/backend/api/Database/Models/MissionTask.cs @@ -1,5 +1,8 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; using Api.Controllers.Models; using Api.Services.Models; using Api.Utilities; @@ -15,21 +18,28 @@ public class MissionTask public MissionTask() { } // ReSharper disable once NotNullOrRequiredMemberIsNotInitialized - public MissionTask(EchoTag echoTag) + public MissionTask( + IList inspections, + Pose robotPose, + int taskOrder, + Uri? tagLink, + string? tagId, + int? poseId, + TaskStatus status = TaskStatus.NotStarted, + MissionTaskType type = MissionTaskType.Inspection) { - Inspections = echoTag.Inspections + Inspections = inspections .Select(inspection => new Inspection(inspection)) .ToList(); - EchoTagLink = echoTag.URL; - TagId = echoTag.TagId; - RobotPose = echoTag.Pose; - EchoPoseId = echoTag.PoseId; - TaskOrder = echoTag.PlanOrder; - Status = TaskStatus.NotStarted; - Type = MissionTaskType.Inspection; + TagLink = tagLink; + TagId = tagId; + RobotPose = robotPose; + PoseId = poseId; + TaskOrder = taskOrder; + Status = status; + Type = type; } - // ReSharper disable once NotNullOrRequiredMemberIsNotInitialized public MissionTask(CustomTaskQuery taskQuery) { Inspections = taskQuery.Inspections @@ -83,9 +93,9 @@ public MissionTask(MissionTask copy, TaskStatus? status = null) TagId = copy.TagId; IsarTaskId = Guid.NewGuid().ToString(); Description = copy.Description; - EchoTagLink = copy.EchoTagLink; + TagLink = copy.TagLink; RobotPose = new Pose(copy.RobotPose); - EchoPoseId = copy.EchoPoseId; + PoseId = copy.PoseId; Status = status ?? copy.Status; Inspections = copy.Inspections.Select(i => new Inspection(i, InspectionStatus.NotStarted)).ToList(); } @@ -111,12 +121,12 @@ public MissionTask(MissionTask copy, TaskStatus? status = null) public string? Description { get; set; } [MaxLength(200)] - public Uri? EchoTagLink { get; set; } + public Uri? TagLink { get; set; } [Required] public Pose RobotPose { get; set; } - public int? EchoPoseId { get; set; } + public int? PoseId { get; set; } [Required] public TaskStatus Status @@ -191,6 +201,25 @@ public static string ConvertMissionTaskTypeToIsarTaskType(MissionTaskType missio }; ; } + + public static string CalculateHashFromTasks(IList tasks) + { + var genericTasks = new List(); + foreach (var task in tasks) + { + var taskCopy = new MissionTask(task) + { + Id = "", + IsarTaskId = "" + }; + taskCopy.Inspections = taskCopy.Inspections.Select(i => new Inspection(i, useEmptyIDs: true)).ToList(); + genericTasks.Add(taskCopy); + } + + string json = JsonSerializer.Serialize(genericTasks); + byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(json)); + return BitConverter.ToString(hash).Replace("-", "", StringComparison.CurrentCulture).ToUpperInvariant(); + } } public enum TaskStatus diff --git a/backend/api/Database/Models/Source.cs b/backend/api/Database/Models/Source.cs index 07942ef03..a06a1856a 100644 --- a/backend/api/Database/Models/Source.cs +++ b/backend/api/Database/Models/Source.cs @@ -13,14 +13,6 @@ public class Source [Required] public string SourceId { get; set; } - [Required] - public MissionSourceType Type { get; set; } - public string? CustomMissionTasks { get; set; } } - - public enum MissionSourceType - { - Echo, Custom - } } diff --git a/backend/api/Database/Models/TransformationMatrices.cs b/backend/api/Database/Models/TransformationMatrices.cs index c16c5a3b6..5805e536e 100644 --- a/backend/api/Database/Models/TransformationMatrices.cs +++ b/backend/api/Database/Models/TransformationMatrices.cs @@ -7,7 +7,7 @@ namespace Api.Database.Models [Owned] public class TransformationMatrices { - // In order to get a pixel coordinate P={p1, p2} from an Echo coordinate E={e1, e2}, we need to + // In order to get a pixel coordinate P={p1, p2} from an asset coordinate E={e1, e2}, we need to // perform the transformation: // P = CE + D [Required] diff --git a/backend/api/Program.cs b/backend/api/Program.cs index 68a2f72cd..21d73d416 100644 --- a/backend/api/Program.cs +++ b/backend/api/Program.cs @@ -7,6 +7,7 @@ using Api.Options; using Api.Services; using Api.Services.ActionServices; +using Api.Services.MissionLoaders; using Api.SignalRHubs; using Api.Utilities; using Azure.Identity; @@ -15,7 +16,6 @@ using Microsoft.AspNetCore.Http.Connections; using Microsoft.AspNetCore.Rewrite; using Microsoft.Identity.Web; -using Microsoft.OpenApi.Models; var builder = WebApplication.CreateBuilder(args); Console.WriteLine($"\nENVIRONMENT IS SET TO '{builder.Environment.EnvironmentName}'\n"); @@ -50,6 +50,8 @@ builder.Services.ConfigureDatabase(builder.Configuration); +builder.Services.ConfigureMissionLoader(builder.Configuration); + builder.Services.AddApplicationInsightsTelemetry(); // Disable Application Insights Telemetry when debugging @@ -72,7 +74,6 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); -builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -138,7 +139,7 @@ .AddMicrosoftIdentityWebApi(builder.Configuration.GetSection("AzureAd")) .EnableTokenAcquisitionToCallDownstreamApi() .AddInMemoryTokenCaches() - .AddDownstreamApi(EchoService.ServiceName, builder.Configuration.GetSection("Echo")) + .AddDownstreamApi(EchoMissionLoader.ServiceName, builder.Configuration.GetSection("Echo")) .AddDownstreamApi(StidService.ServiceName, builder.Configuration.GetSection("Stid")) .AddDownstreamApi(IsarService.ServiceName, builder.Configuration.GetSection("Isar")); @@ -156,8 +157,8 @@ c.PreSerializeFilters.Add( (swaggerDoc, httpReq) => { - swaggerDoc.Servers = new List - { + swaggerDoc.Servers = + [ new() { Url = $"https://{httpReq.Host.Value}{basePath}" @@ -166,7 +167,7 @@ { Url = $"http://{httpReq.Host.Value}{basePath}" } - }; + ]; } ); } diff --git a/backend/api/Services/MissionDefinitionService.cs b/backend/api/Services/MissionDefinitionService.cs index b047c301a..0a2765375 100644 --- a/backend/api/Services/MissionDefinitionService.cs +++ b/backend/api/Services/MissionDefinitionService.cs @@ -4,6 +4,7 @@ using Api.Controllers.Models; using Api.Database.Context; using Api.Database.Models; +using Api.Services.MissionLoaders; using Api.Utilities; using Microsoft.EntityFrameworkCore; namespace Api.Services @@ -20,7 +21,7 @@ public interface IMissionDefinitionService public Task> ReadByDeckId(string deckId); - public Task?> GetTasksFromSource(Source source, string installationCodes); + public Task?> GetTasksFromSource(Source source); public Task> ReadBySourceId(string sourceId); @@ -42,8 +43,7 @@ public interface IMissionDefinitionService Justification = "Entity framework does not support translating culture info to SQL calls" )] public class MissionDefinitionService(FlotillaDbContext context, - IEchoService echoService, - ISourceService sourceService, + IMissionLoader missionLoader, ISignalRService signalRService, IAccessRoleService accessRoleService, ILogger logger, @@ -57,7 +57,7 @@ public async Task Create(MissionDefinition missionDefinition) await context.MissionDefinitions.AddAsync(missionDefinition); await ApplyDatabaseUpdate(missionDefinition.Area?.Installation); - _ = signalRService.SendMessageAsync("Mission definition created", missionDefinition.Area?.Installation, new CondensedMissionDefinitionResponse(missionDefinition)); + _ = signalRService.SendMessageAsync("Mission definition created", missionDefinition.Area?.Installation, new MissionDefinitionResponse(missionDefinition)); return missionDefinition; } @@ -134,7 +134,7 @@ public async Task Update(MissionDefinition missionDefinition) var entry = context.Update(missionDefinition); await ApplyDatabaseUpdate(missionDefinition.Area?.Installation); - _ = signalRService.SendMessageAsync("Mission definition updated", missionDefinition?.Area?.Installation, missionDefinition != null ? new CondensedMissionDefinitionResponse(missionDefinition) : null); + _ = signalRService.SendMessageAsync("Mission definition updated", missionDefinition?.Area?.Installation, missionDefinition != null ? new MissionDefinitionResponse(missionDefinition) : null); return entry.Entity; } @@ -150,30 +150,9 @@ public async Task Update(MissionDefinition missionDefinition) return missionDefinition; } - public async Task?> GetTasksFromSource(Source source, string installationCode) + public async Task?> GetTasksFromSource(Source source) { - try - { - return source.Type switch - { - MissionSourceType.Echo => - // CultureInfo is not important here since we are not using decimal points - echoService.GetMissionById( - int.Parse(source.SourceId, new CultureInfo("en-US")) - ).Result.Tags - .Select(t => new MissionTask(t)) - .ToList(), - MissionSourceType.Custom => - await sourceService.GetMissionTasksFromSourceId(source.SourceId), - _ => - throw new MissionSourceTypeException($"Mission type {source.Type} is not accounted for") - }; - } - catch (FormatException) - { - logger.LogError("Echo source ID was not formatted correctly"); - throw new FormatException("Echo source ID was not formatted correctly"); - } + return await missionLoader.GetTasksForMission(source.SourceId); } private async Task ApplyDatabaseUpdate(Installation? installation) @@ -222,7 +201,6 @@ private static void SearchByName(ref IQueryable missionDefini /// Filters by /// and /// and - /// and /// /// Uses LINQ Expression trees (see /// ) @@ -243,21 +221,13 @@ MissionDefinitionQueryStringParameters parameters : missionDefinition => missionDefinition.InstallationCode.ToLower().Equals(parameters.InstallationCode.Trim().ToLower()); - Expression> missionTypeFilter = parameters.SourceType is null - ? missionDefinition => true - : missionDefinition => - missionDefinition.Source.Type.Equals(parameters.SourceType); - // The parameter of the filter expression var missionDefinitionExpression = Expression.Parameter(typeof(MissionDefinition)); // Combining the body of the filters to create the combined filter, using invoke to force parameter substitution Expression body = Expression.AndAlso( Expression.Invoke(installationFilter, missionDefinitionExpression), - Expression.AndAlso( - Expression.Invoke(areaFilter, missionDefinitionExpression), - Expression.Invoke(missionTypeFilter, missionDefinitionExpression) - ) + Expression.Invoke(areaFilter, missionDefinitionExpression) ); // Constructing the resulting lambda expression by combining parameter and body diff --git a/backend/api/Controllers/Models/EchoInspection.cs b/backend/api/Services/MissionLoaders/EchoInspection.cs similarity index 95% rename from backend/api/Controllers/Models/EchoInspection.cs rename to backend/api/Services/MissionLoaders/EchoInspection.cs index eabaa8b26..3edb13db6 100644 --- a/backend/api/Controllers/Models/EchoInspection.cs +++ b/backend/api/Services/MissionLoaders/EchoInspection.cs @@ -1,5 +1,6 @@ -using Api.Database.Models; -namespace Api.Controllers.Models +using Api.Controllers.Models; +using Api.Database.Models; +namespace Api.Services.MissionLoaders { public class EchoInspection { diff --git a/backend/api/Controllers/Models/EchoMission.cs b/backend/api/Services/MissionLoaders/EchoMission.cs similarity index 77% rename from backend/api/Controllers/Models/EchoMission.cs rename to backend/api/Services/MissionLoaders/EchoMission.cs index 2b8a27765..978b9f330 100644 --- a/backend/api/Controllers/Models/EchoMission.cs +++ b/backend/api/Services/MissionLoaders/EchoMission.cs @@ -1,9 +1,9 @@ # nullable disable -namespace Api.Controllers.Models +namespace Api.Services.MissionLoaders { public class EchoMission { - public int Id { get; set; } + public string Id { get; set; } public string Name { get; set; } diff --git a/backend/api/Services/EchoService.cs b/backend/api/Services/MissionLoaders/EchoMissionLoader.cs similarity index 58% rename from backend/api/Services/EchoService.cs rename to backend/api/Services/MissionLoaders/EchoMissionLoader.cs index 32d001fa9..6ef598a93 100644 --- a/backend/api/Services/EchoService.cs +++ b/backend/api/Services/MissionLoaders/EchoMissionLoader.cs @@ -1,24 +1,20 @@ -using System.Text.Json; +using System.Globalization; +using System.Text.Json; using Api.Controllers.Models; using Api.Database.Models; using Api.Utilities; using Microsoft.Identity.Abstractions; -namespace Api.Services +namespace Api.Services.MissionLoaders { - public interface IEchoService - { - public Task> GetAvailableMissions(string? installationCode); - - public Task GetMissionById(int missionId); - - public Task> GetEchoPlantInfos(); - } - - public class EchoService(IDownstreamApi echoApi, ILogger logger) : IEchoService + public class EchoMissionLoader( + IDownstreamApi echoApi, + ISourceService sourceService, + IStidService stidService, + ILogger logger) : IMissionLoader { public const string ServiceName = "EchoApi"; - public async Task> GetAvailableMissions(string? installationCode) + public async Task> GetAvailableMissions(string? installationCode) { string relativePath = string.IsNullOrEmpty(installationCode) ? "robots/robot-plan?Status=Ready" @@ -38,14 +34,38 @@ public async Task> GetAvailableMissions(st var echoMissions = await response.Content.ReadFromJsonAsync< List >() ?? throw new JsonException("Failed to deserialize missions from Echo"); - var availableMissions = ProcessAvailableEchoMission(echoMissions); - return availableMissions; + var availableMissions = new List(); + + foreach (var echoMissionResponse in echoMissions) + { + var echoMission = ProcessEchoMission(echoMissionResponse); + if (echoMission == null) + { + continue; + } + var missionDefinition = await EchoMissionToMissionDefinition(echoMission); + if (missionDefinition == null) + { + continue; + } + availableMissions.Add(missionDefinition); + } + + return availableMissions.AsQueryable(); } - public async Task GetMissionById(int missionId) + public async Task GetMissionById(string sourceMissionId) { - string relativePath = $"robots/robot-plan/{missionId}"; + var echoMission = await GetEchoMission(sourceMissionId); + + var mission = await EchoMissionToMissionDefinition(echoMission); + return mission; + } + + private async Task GetEchoMission(string echoMissionId) + { + string relativePath = $"robots/robot-plan/{echoMissionId}"; var response = await echoApi.CallApiForUserAsync( ServiceName, @@ -59,11 +79,59 @@ public async Task GetMissionById(int missionId) response.EnsureSuccessStatusCode(); var echoMission = await response.Content.ReadFromJsonAsync() ?? throw new JsonException("Failed to deserialize mission from Echo"); - var processedEchoMission = ProcessEchoMission(echoMission) ?? throw new InvalidDataException($"EchoMission with id: {missionId} is invalid"); + var processedEchoMission = ProcessEchoMission(echoMission) ?? throw new InvalidDataException($"EchoMission with id: {echoMissionId} is invalid"); return processedEchoMission; } - public async Task> GetEchoPlantInfos() + public async Task> GetTasksForMission(string missionId) + { + var echoMission = await GetEchoMission(missionId); + var missionTasks = echoMission.Tags.Select(t => MissionTaskFromEchoTag(t)).ToList(); + return missionTasks; + } + + private async Task EchoMissionToMissionDefinition(EchoMission echoMission) + { + var source = await sourceService.CheckForExistingSource(echoMission.Id) ?? await sourceService.Create( + new Source + { + SourceId = $"{echoMission.Id}", + } + ); + var missionTasks = echoMission.Tags; + List missionAreas; + missionAreas = missionTasks + .Where(t => t.TagId != null) + .Select(t => stidService.GetTagArea(t.TagId, echoMission.InstallationCode).Result) + .ToList(); + + var missionDeckNames = missionAreas.Where(a => a != null).Select(a => a!.Deck.Name).Distinct().ToList(); + if (missionDeckNames.Count > 1) + { + string joinedMissionDeckNames = string.Join(", ", [.. missionDeckNames]); + logger.LogWarning($"Mission {echoMission.Name} has tags on more than one deck. The decks are: {joinedMissionDeckNames}."); + } + + Area? area = null; + area = missionAreas.GroupBy(i => i).OrderByDescending(grp => grp.Count()).Select(grp => grp.Key).First(); + + if (area == null) + { + return null; + } + + var missionDefinition = new MissionDefinition + { + Id = Guid.NewGuid().ToString(), + Source = source, + Name = echoMission.Name, + InstallationCode = echoMission.InstallationCode, + Area = area + }; + return missionDefinition; + } + + public async Task> GetPlantInfos() { string relativePath = "plantinfo"; var response = await echoApi.CallApiForUserAsync( @@ -135,38 +203,6 @@ private static List ProcessPlanItems(List planItems, string i return tags; } - private List ProcessAvailableEchoMission(List echoMissions) - { - var availableMissions = new List(); - - foreach (var echoMission in echoMissions) - { - if (echoMission.PlanItems is null) - { - continue; - } - try - { - var condensedEchoMissionDefinition = new CondensedEchoMissionDefinition - { - EchoMissionId = echoMission.Id, - Name = echoMission.Name, - InstallationCode = echoMission.InstallationCode - }; - availableMissions.Add(condensedEchoMissionDefinition); - } - catch (InvalidDataException e) - { - logger.LogWarning( - "Echo mission with ID '{Id}' is invalid: '{Message}'", - echoMission.Id, - e.Message - ); - } - } - return availableMissions; - } - private EchoMission? ProcessEchoMission(EchoMissionResponse echoMission) { if (echoMission.PlanItems is null) @@ -177,7 +213,7 @@ private List ProcessAvailableEchoMission(List ProcessAvailableEchoMission(List ProcessEchoPlantInfos( + private static List ProcessEchoPlantInfos( List echoPlantInfoResponse ) { - var echoPlantInfos = new List(); + var echoPlantInfos = new List(); foreach (var plant in echoPlantInfoResponse) { if (plant.InstallationCode is null || plant.ProjectDescription is null) @@ -208,7 +244,7 @@ List echoPlantInfoResponse continue; } - var echoPlantInfo = new EchoPlantInfo + var echoPlantInfo = new PlantInfo { PlantCode = plant.InstallationCode, ProjectDescription = plant.ProjectDescription @@ -218,5 +254,26 @@ List echoPlantInfoResponse } return echoPlantInfos; } + + public MissionTask MissionTaskFromEchoTag(EchoTag echoTag) + { + return new MissionTask + ( + inspections: echoTag.Inspections + .Select(inspection => new Inspection( + inspectionType: inspection.InspectionType, + videoDuration: inspection.TimeInSeconds, + inspection.InspectionPoint, + status: InspectionStatus.NotStarted)) + .ToList(), + tagLink: echoTag.URL, + tagId: echoTag.TagId, + robotPose: echoTag.Pose, + poseId: echoTag.PoseId, + taskOrder: echoTag.PlanOrder, + status: Database.Models.TaskStatus.NotStarted, + type: MissionTaskType.Inspection + ); + } } } diff --git a/backend/api/Controllers/Models/EchoMissionResponse.cs b/backend/api/Services/MissionLoaders/EchoMissionResponse.cs similarity index 98% rename from backend/api/Controllers/Models/EchoMissionResponse.cs rename to backend/api/Services/MissionLoaders/EchoMissionResponse.cs index af280cfd8..595cfe525 100644 --- a/backend/api/Controllers/Models/EchoMissionResponse.cs +++ b/backend/api/Services/MissionLoaders/EchoMissionResponse.cs @@ -81,7 +81,7 @@ public class SensorType public class EchoPose { [JsonPropertyName("poseId")] - public int PoseId { get; set; } + public int? PoseId { get; set; } [JsonPropertyName("installationCode")] public string InstallationCode { get; set; } diff --git a/backend/api/Controllers/Models/EchoPlantInfoResponse.cs b/backend/api/Services/MissionLoaders/EchoPlantInfoResponse.cs similarity index 95% rename from backend/api/Controllers/Models/EchoPlantInfoResponse.cs rename to backend/api/Services/MissionLoaders/EchoPlantInfoResponse.cs index 2ba935827..504202073 100644 --- a/backend/api/Controllers/Models/EchoPlantInfoResponse.cs +++ b/backend/api/Services/MissionLoaders/EchoPlantInfoResponse.cs @@ -1,7 +1,7 @@ #nullable enable using System.Text.Json.Serialization; -namespace Api.Controllers.Models +namespace Api.Services.MissionLoaders { public class EchoPlantInfoResponse { diff --git a/backend/api/Controllers/Models/EchoTag.cs b/backend/api/Services/MissionLoaders/EchoTag.cs similarity index 82% rename from backend/api/Controllers/Models/EchoTag.cs rename to backend/api/Services/MissionLoaders/EchoTag.cs index 2692cc1a0..18a207270 100644 --- a/backend/api/Controllers/Models/EchoTag.cs +++ b/backend/api/Services/MissionLoaders/EchoTag.cs @@ -1,7 +1,7 @@ #nullable disable using Api.Database.Models; -namespace Api.Controllers.Models +namespace Api.Services.MissionLoaders { public class EchoTag { @@ -11,7 +11,7 @@ public class EchoTag public int PlanOrder { get; set; } - public int PoseId { get; set; } + public int? PoseId { get; set; } public Pose Pose { get; set; } diff --git a/backend/api/Services/MissionLoaders/MissionLoaderInterface.cs b/backend/api/Services/MissionLoaders/MissionLoaderInterface.cs new file mode 100644 index 000000000..6531bcd2b --- /dev/null +++ b/backend/api/Services/MissionLoaders/MissionLoaderInterface.cs @@ -0,0 +1,15 @@ +using Api.Controllers.Models; +using Api.Database.Models; +namespace Api.Services.MissionLoaders +{ + public interface IMissionLoader + { + public Task GetMissionById(string sourceMissionId); + + public Task> GetAvailableMissions(string? installationCode); + + public Task> GetTasksForMission(string sourceMissionId); + + public Task> GetPlantInfos(); // Facility service + } +} diff --git a/backend/api/Services/SourceService.cs b/backend/api/Services/SourceService.cs index ca5194ea5..57fe4f514 100644 --- a/backend/api/Services/SourceService.cs +++ b/backend/api/Services/SourceService.cs @@ -1,10 +1,4 @@ -using System.Globalization; -using System.Linq.Dynamic.Core; -using System.Linq.Expressions; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using Api.Controllers.Models; +using System.Text.Json; using Api.Database.Context; using Api.Database.Models; using Api.Utilities; @@ -16,24 +10,16 @@ public interface ISourceService { public abstract Task Create(Source source); - public abstract Task> ReadAll(SourceQueryStringParameters? parameters); - - public abstract Task ReadByIdAndInstallationWithTasks(string id, string installationCode); - - public abstract Task ReadByIdWithTasks(string id); + public abstract Task> ReadAll(); public abstract Task ReadById(string id); - public abstract Task CheckForExistingEchoSource(int echoId); - - public abstract Task CheckForExistingCustomSource(IList tasks); + public abstract Task CheckForExistingSource(string sourceId); - public abstract Task?> GetMissionTasksFromSourceId(string id); + public abstract Task CheckForExistingSourceFromTasks(IList tasks); public abstract Task CreateSourceIfDoesNotExist(List tasks); - public abstract string CalculateHashFromTasks(IList tasks); - public abstract Task Update(Source source); public abstract Task Delete(string id); @@ -47,7 +33,6 @@ public interface ISourceService )] public class SourceService( FlotillaDbContext context, - IEchoService echoService, ILogger logger) : ISourceService { public async Task Create(Source source) @@ -57,19 +42,11 @@ public async Task Create(Source source) return source; } - public async Task> ReadAll(SourceQueryStringParameters? parameters) + public async Task> ReadAll() { var query = GetSources(); - parameters ??= new SourceQueryStringParameters { }; - var filter = ConstructFilter(parameters); - - var filteredQuery = query.Where(filter); - return await PagedList.ToPagedListAsync( - filteredQuery, - parameters.PageNumber, - parameters.PageSize - ); + return await query.ToListAsync(); } private DbSet GetSources() @@ -89,55 +66,14 @@ private DbSet GetSources() .FirstOrDefaultAsync(s => s.SourceId.Equals(sourceId)); } - public async Task ReadByIdAndInstallationWithTasks(string id, string installationCode) + public async Task CheckForExistingSource(string sourceId) { - var source = await GetSources() - .FirstOrDefaultAsync(s => s.Id.Equals(id)); - if (source == null) return null; - - switch (source.Type) - { - case MissionSourceType.Custom: - throw new ArgumentException("Source is not of type Echo"); - case MissionSourceType.Echo: - var mission = await echoService.GetMissionById(int.Parse(source.SourceId, new CultureInfo("en-US"))); - var tasks = mission.Tags.Select(t => - { - return new MissionTask(t); - }).ToList(); - return new SourceResponse(source, tasks); - default: - return null; - } + return await ReadBySourceId(sourceId); } - public async Task ReadByIdWithTasks(string id) + public async Task CheckForExistingSourceFromTasks(IList tasks) { - var source = await GetSources() - .FirstOrDefaultAsync(s => s.Id.Equals(id)); - if (source == null) return null; - - switch (source.Type) - { - case MissionSourceType.Custom: - var tasks = await GetMissionTasksFromSourceId(source.SourceId); - if (tasks == null) return null; - return new SourceResponse(source, tasks); - case MissionSourceType.Echo: - throw new ArgumentException("Source is not of type Custom"); - default: - return null; - } - } - - public async Task CheckForExistingEchoSource(int echoId) - { - return await ReadBySourceId(echoId.ToString(CultureInfo.CurrentCulture)); - } - - public async Task CheckForExistingCustomSource(IList tasks) - { - string hash = CalculateHashFromTasks(tasks); + string hash = MissionTask.CalculateHashFromTasks(tasks); return await ReadBySourceId(hash); } @@ -169,7 +105,7 @@ private DbSet GetSources() public async Task CreateSourceIfDoesNotExist(List tasks) { string json = JsonSerializer.Serialize(tasks); - string hash = CalculateHashFromTasks(tasks); + string hash = MissionTask.CalculateHashFromTasks(tasks); var existingSource = await ReadById(hash); @@ -179,7 +115,6 @@ public async Task CreateSourceIfDoesNotExist(List tasks) new Source { SourceId = hash, - Type = MissionSourceType.Custom, CustomMissionTasks = json } ); @@ -187,25 +122,6 @@ public async Task CreateSourceIfDoesNotExist(List tasks) return newSource; } - public string CalculateHashFromTasks(IList tasks) - { - var genericTasks = new List(); - foreach (var task in tasks) - { - var taskCopy = new MissionTask(task) - { - Id = "", - IsarTaskId = "" - }; - taskCopy.Inspections = taskCopy.Inspections.Select(i => new Inspection(i, useEmptyIDs: true)).ToList(); - genericTasks.Add(taskCopy); - } - - string json = JsonSerializer.Serialize(genericTasks); - byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(json)); - return BitConverter.ToString(hash).Replace("-", "", StringComparison.CurrentCulture).ToUpperInvariant(); - } - public async Task Update(Source source) { var entry = context.Update(source); @@ -227,24 +143,5 @@ public async Task Update(Source source) return source; } - - private static Expression> ConstructFilter( - SourceQueryStringParameters parameters - ) - { - Expression> missionTypeFilter = parameters.Type is null - ? source => true - : source => - source.Type == parameters.Type; - - // The parameter of the filter expression - var sourceExpression = Expression.Parameter(typeof(Source)); - - // Combining the body of the filters to create the combined filter, using invoke to force parameter substitution - Expression body = Expression.Invoke(missionTypeFilter, sourceExpression); - - // Constructing the resulting lambda expression by combining parameter and body - return Expression.Lambda>(body, sourceExpression); - } } } diff --git a/backend/api/appsettings.json b/backend/api/appsettings.json index c5f78f192..020e07ffb 100644 --- a/backend/api/appsettings.json +++ b/backend/api/appsettings.json @@ -35,6 +35,9 @@ "Database": { "ConnectionString": "" }, + "MissionLoader": { + "FileName": "Api.Services.MissionLoaders.EchoMissionLoader" + }, "KeyVault": { "UseKeyVault": true }, diff --git a/frontend/best_practises.md b/frontend/best_practises.md index 420674a69..91bec1ca3 100644 --- a/frontend/best_practises.md +++ b/frontend/best_practises.md @@ -1,20 +1,20 @@ # Flotilla frontend development best practises -- [Flotilla frontend development best practises](#flotilla-frontend-development-best-practises) - - [Setup](#setup) - - [Prettier](#prettier) - - [ESLint](#eslint) - - [Folder structure](#folder-structure) - - [Components](#react-components) - - [React arguments](#react-arguments) - - [React functions](#react-state) - - [React state](#react-state) - - [Nesting](#nesting) - - [Contexts](#contexts) - - [SignalR](#signalr) - - [Functional programming](#functional-programming) - - [Declarative programming](#declarative-programming) - - [Input](#input) +- [Flotilla frontend development best practises](#flotilla-frontend-development-best-practises) + - [Setup](#setup) + - [Prettier](#prettier) + - [ESLint](#eslint) + - [Folder structure](#folder-structure) + - [Components](#react-components) + - [React arguments](#react-arguments) + - [React functions](#react-state) + - [React state](#react-state) + - [Nesting](#nesting) + - [Contexts](#contexts) + - [SignalR](#signalr) + - [Functional programming](#functional-programming) + - [Declarative programming](#declarative-programming) + - [Input](#input) ## Setup @@ -22,29 +22,30 @@ See the [README](./README.md) for more information. ### Prettier -We abide by the formatting provided by Prettier. To run it, type - npx prettier --write [path to source] +We abide by the formatting provided by Prettier. To run it, type +npx prettier --write [path to source] ### ESLint We also avoid any warnings or errors from ESLint before we merge in any code. These warnings appear when compiling the code using - npm start +npm start but can also be run with - npx eslint [path to src] +npx eslint [path to src] ## Folder structure The frontend src folder is organised into 6 main folders. -- Alerts contains code which displays alerts on the top of the page -- Contexts contain react contexts (see [the context section for more information](#contexts)) -- Displays contain visual react component which are used on more than one page -- Header contains code related to the page header -- Pages contains the bulk of the code, as all the code related to the website pages are kept here, if there are no other relevant folders -- Language contains translations between the supported languages for text on the Flotilla web page -- MediaAssets contains the static image files displayed on the page -- Models contains the data models -- Utils contain utility functions which are relevant in several parts of the code + +- Alerts contains code which displays alerts on the top of the page +- Contexts contain react contexts (see [the context section for more information](#contexts)) +- Displays contain visual react component which are used on more than one page +- Header contains code related to the page header +- Pages contains the bulk of the code, as all the code related to the website pages are kept here, if there are no other relevant folders +- Language contains translations between the supported languages for text on the Flotilla web page +- MediaAssets contains the static image files displayed on the page +- Models contains the data models +- Utils contain utility functions which are relevant in several parts of the code ## Function syntax @@ -69,6 +70,7 @@ The flotilla frontend is programmed in Typescript using the React framework. In React arguments should be descriptively named in relation to the components name, so that someone would not need to investigate the parent component to learn what the data represents. If only parts of the provided data is utilised, then it is often better to only send the relevant data (eg only send mission name instead of the whole mission if only the name is used). Additionally, an interface should be utilised when there are many components, so as to make it easier to read. This interface should be placed just above, or at least nearby, the function itself. Good (although the interface is not strictly neccessary here): + ``` interface MissionTitleComponentProps { missionName: string @@ -80,8 +82,9 @@ export const MissionTitleComponent = ({ missionName } : MissionTitleComponentPro ``` Bad: + ``` -export const RobotComponent = ({ mission, setColor } : {mission: CondensedMissionDefinition, setColor: (c: string) => void}) => { +export const RobotComponent = ({ mission, setColor } : {mission: MissionDefinition, setColor: (c: string) => void}) => { ... } ``` @@ -104,7 +107,9 @@ const ExampleComponent = ({ x: number } : { x: number }) => { ) } ``` -it can be simplified to + +it can be simplified to + ``` const ExampleComponent = ({ x: number } : { x: number }) => { setNumberToDisplay = x + 1 @@ -113,12 +118,15 @@ const ExampleComponent = ({ x: number } : { x: number }) => { ) } ``` + although in this contrived example it would be easier to give a more meaningful name to 'x' and then write + ``` const ExampleComponent = ({ x: number } : { x: number }) =>
{x + 1}
``` It is also worth noting that calls to the update functions of react state are grouped together at the end of each render. So in the following code: + ``` const [x, setX] = useState(0) @@ -129,7 +137,9 @@ useEffect(() => { set(x + 1) }, [x]) ``` + we do not yet see the updated value of x in the same render we updated it. Additionally, if we call setX several times in one render (ie. inside the same useEffect), they will overwrite each other. 'x' will be 1 at the end of this render, not 2. In order to prevent this overwriting we can instead pass a function to the set function which describes how to update the variable using its current value. This will be done in turn for each call to setX in this case, prevent updates from being overwritten. + ``` const [x, setX] = useState(0) @@ -138,6 +148,7 @@ useEffect(() => { setX((oldX) => oldX + 1) }, [x]) ``` + At the end of the above code the value of 'x' will be 2, as the second update call will use the output from the first update call as the input to its function. This is important to keep in mind in event handlers, as the state inside for instance signalR event handlers is frozen when they are first registered. ### Nesting @@ -152,11 +163,10 @@ React contexts are an important tool to maintain state across components. They w It is important to remember to include the provider in the top level of the program, and to remember that contexts cannot see other contexts whose providers are lower than them in the hierarchy. -The main use of contexts is to store state which is used in more than one component. Any react state defined in the context can be imported in other components, and this state will be identical for each of them. This is vital to allow the state to remain the same whilst moving between pages, as the data in the react components would otherwise be reset each time they stop being rendered. +The main use of contexts is to store state which is used in more than one component. Any react state defined in the context can be imported in other components, and this state will be identical for each of them. This is vital to allow the state to remain the same whilst moving between pages, as the data in the react components would otherwise be reset each time they stop being rendered. Treating the contexts as data aggregates also simplifies each component as all the code related to fetching and formatting data from the backend can be moved to contexts. In particular it is ideal to move signalR event handlers to contexts as these can make components difficult to read otherwise. In effect we treat the contexts as light versions of redux stores, where we fetch the data from the context, and then the context can also expose functions which allow us to update the state in the context. This allows us to better control what data is visible and how it is possible to update it. A great example of this can be seen in [the mission filter context](./src/components/Contexts/MissionFilterContext.tsx) and in [the filter component where it's used](./src/components/Pages/MissionHistoryPage/FilterSection.tsx). - ## SignalR Information on the best practises related to SignalR can be found in [the signalR context](./src/components/Contexts/SignalRContext.tsx). @@ -165,7 +175,7 @@ Information on the best practises related to SignalR can be found in [the signal Functional programming is a large field, but for the sake of this document we are interested in containing side effects within functions. This means that we do not want functions to change any state other that the arguments provided. The actual objects sent as arguments should also not be changed themselves, instead the result of performing manipulations on them should be returned as a new state at the end of the function. This form of self contained function is called a pure function. -In react there are two main side-effects, updating react state using set functions, and sending/receiving data to/from the backend. This is not avoidable, but we can contain them to be done inside useEffects. In these useEffects the side-effects should be performed at the top level instead of inside nested calls. Functions calls can be made inside the useEffects for the sake of manipulation of the data and formatting, but the end result should be returned to the useEffect before a set call is made. In effect, no functions should have a 'void' return type when possible. Setting state inside event handlers is another avoidable situation, besides useEffects. +In react there are two main side-effects, updating react state using set functions, and sending/receiving data to/from the backend. This is not avoidable, but we can contain them to be done inside useEffects. In these useEffects the side-effects should be performed at the top level instead of inside nested calls. Functions calls can be made inside the useEffects for the sake of manipulation of the data and formatting, but the end result should be returned to the useEffect before a set call is made. In effect, no functions should have a 'void' return type when possible. Setting state inside event handlers is another avoidable situation, besides useEffects. Making the code as functional as possible does not only make it more readable, it also reduces the chances of errors being introduced as it forces us to make many simple functions which do isolated operations. Additionally it moves the state operations to one place, making any previous mistakes more obvious. @@ -174,6 +184,7 @@ Using coding styles common in functional languages are also encouraged in Typesc In the following examples the functions are kept simple for the sake of demonstration. Bad (all updates are kept obfuscated inside the updateLists function): + ``` const [x, setX] = useState(0) const [numberList, setNumberList] = useState(0) @@ -197,6 +208,7 @@ useEffect(() => { ``` Better (the updates of each list is separated, and the number is passed as an argument): + ``` const [x, setX] = useState(0) const [numberList, setNumberList] = useState(0) @@ -222,6 +234,7 @@ useEffect(() => { ``` Best (The functions only manipulate the given arguments and does not access any react state directly, whilst all the state updates are being done in the useEffect on the top level): + ``` const [x, setX] = useState(0) const [numberList, setNumberList] = useState(0) @@ -247,6 +260,7 @@ useEffect(() => { ``` If we do the following then we also prevent any conflicts that would arise from having multiple updates to numberList and otherNumberList in the same render, but that is not the case in this simple example. + ``` useEffect(() => { setNumberList((oldList) => removeNumberFromList(x, oldList)) @@ -265,6 +279,7 @@ It is also good to not be afraid to define local small react components inside r Here are some examples for good and bad practises. 'getIndexDisplay' is small enough that it would not need to be separated from the react function return statement, but here it is just used as an example. Bad (A multiline function being called inside the HTML object): + ``` const FooComponent = ({ index }: { index: number }) => { const getIndexDisplay(x: number) => { @@ -278,6 +293,7 @@ const FooComponent = ({ index }: { index: number }) => { ``` Better (The function has been inlined so that it becomes a simple mapping from input to output): + ``` const FooComponent = ({ index }: { index: number }) => { const getIndexDisplay(x: number) =>

{x + 1}

@@ -289,6 +305,7 @@ const FooComponent = ({ index }: { index: number }) => { ``` Good (The function has become a react component, so that it does not need to be excplicitly called): + ``` const FooComponent = ({ index }: { index: number }) => { const IndexDisplay({ x: number }: { x: number }) =>

{x + 1}

@@ -314,4 +331,5 @@ Here is an example of a controlled input component: }} /> ``` + The filterFunctions.switchTagId function sets the state of filterState.tagId diff --git a/frontend/src/api/ApiCaller.tsx b/frontend/src/api/ApiCaller.tsx index 16a2f4c76..6d7308b2b 100644 --- a/frontend/src/api/ApiCaller.tsx +++ b/frontend/src/api/ApiCaller.tsx @@ -1,5 +1,4 @@ import { config } from 'config' -import { EchoPlantInfo } from 'models/EchoMission' import { Mission } from 'models/Mission' import { Robot } from 'models/Robot' import { VideoStream } from 'models/VideoStream' @@ -11,8 +10,7 @@ import { Area } from 'models/Area' import { timeout } from 'utils/timeout' import { tokenReverificationInterval } from 'components/Contexts/AuthProvider' import { MapMetadata } from 'models/MapMetadata' -import { CondensedMissionDefinition, EchoMissionDefinition } from 'models/MissionDefinition' -import { EchoMission } from 'models/EchoMission' +import { MissionDefinition, PlantInfo } from 'models/MissionDefinition' import { MissionDefinitionUpdateForm } from 'models/MissionDefinitionUpdateForm' import { Deck } from 'models/Deck' import { ApiError, isApiError } from './ApiError' @@ -143,12 +141,6 @@ export class BackendAPICaller { return result.content } - static async getAllEchoMissions(): Promise { - const path: string = 'echo/missions' - const result = await BackendAPICaller.GET(path).catch(BackendAPICaller.handleError('GET', path)) - return result.content - } - static async getMissionRuns(parameters: MissionRunQueryParameters): Promise> { let path: string = 'missions/runs?' @@ -193,9 +185,9 @@ export class BackendAPICaller { return { pagination: pagination, content: result.content } } - static async getAvailableEchoMissions(installationCode: string = ''): Promise { - const path: string = 'echo/available-missions/' + installationCode - const result = await BackendAPICaller.GET(path).catch( + static async getAvailableMissions(installationCode: string = ''): Promise { + const path: string = 'mission-loader/available-missions/' + installationCode + const result = await BackendAPICaller.GET(path).catch( BackendAPICaller.handleError('GET', path) ) return result.content @@ -203,22 +195,21 @@ export class BackendAPICaller { static async getMissionDefinitions( parameters: MissionDefinitionQueryParameters - ): Promise> { - let path: string = 'missions/definitions/condensed?' + ): Promise> { + let path: string = 'missions/definitions?' // Always filter by currently selected installation const installationCode: string | null = BackendAPICaller.installationCode if (installationCode) path = path + 'InstallationCode=' + installationCode + '&' if (parameters.area) path = path + 'Area=' + parameters.area + '&' - if (parameters.sourceType) path = path + 'SourceType=' + parameters.sourceType + '&' + if (parameters.sourceId) path = path + 'SourceId=' + parameters.sourceId + '&' if (parameters.pageNumber) path = path + 'PageNumber=' + parameters.pageNumber + '&' if (parameters.pageSize) path = path + 'PageSize=' + parameters.pageSize + '&' if (parameters.orderBy) path = path + 'OrderBy=' + parameters.orderBy + '&' if (parameters.nameSearch) path = path + 'NameSearch=' + parameters.nameSearch + '&' - if (parameters.sourceType) path = path + 'SourceType=' + parameters.sourceType + '&' - const result = await BackendAPICaller.GET(path).catch( + const result = await BackendAPICaller.GET(path).catch( BackendAPICaller.handleError('GET', path) ) if (!result.headers.has(PaginationHeaderName)) { @@ -228,33 +219,29 @@ export class BackendAPICaller { return { pagination: pagination, content: result.content } } - static async getMissionDefinitionsInArea(area: Area): Promise { + static async getMissionDefinitionsInArea(area: Area): Promise { let path: string = 'areas/' + area.id + '/mission-definitions' - const result = await BackendAPICaller.GET(path).catch( + const result = await BackendAPICaller.GET(path).catch( BackendAPICaller.handleError('GET', path) ) return result.content } - static async getMissionDefinitionsInDeck(deck: Deck): Promise { + static async getMissionDefinitionsInDeck(deck: Deck): Promise { let path: string = 'decks/' + deck.id + '/mission-definitions' - const result = await BackendAPICaller.GET(path).catch( + const result = await BackendAPICaller.GET(path).catch( BackendAPICaller.handleError('GET', path) ) return result.content } - static async updateMissionDefinition( - id: string, - form: MissionDefinitionUpdateForm - ): Promise { + static async updateMissionDefinition(id: string, form: MissionDefinitionUpdateForm): Promise { const path: string = 'missions/definitions/' + id - const result = await BackendAPICaller.PUT( - path, - form - ).catch(BackendAPICaller.handleError('PUT', path)) + const result = await BackendAPICaller.PUT(path, form).catch( + BackendAPICaller.handleError('PUT', path) + ) return result.content } @@ -263,9 +250,9 @@ export class BackendAPICaller { await BackendAPICaller.DELETE(path, '').catch(BackendAPICaller.handleError('DELETE', path)) } - static async getMissionDefinitionById(missionId: string): Promise { - const path: string = 'missions/definitions/' + missionId + '/condensed' - const result = await BackendAPICaller.GET(path).catch( + static async getMissionDefinitionById(missionId: string): Promise { + const path: string = 'missions/definitions/' + missionId + const result = await BackendAPICaller.GET(path).catch( BackendAPICaller.handleError('GET', path) ) return result.content @@ -283,29 +270,25 @@ export class BackendAPICaller { return result.content } - static async getEchoPlantInfo(): Promise { - const path: string = 'echo/plants' - const result = await BackendAPICaller.GET(path).catch( - BackendAPICaller.handleError('GET', path) - ) + static async getPlantInfo(): Promise { + const path: string = 'mission-loader/plants' + const result = await BackendAPICaller.GET(path).catch(BackendAPICaller.handleError('GET', path)) return result.content } - static async getActivePlants(): Promise { - const path: string = 'echo/active-plants' - const result = await BackendAPICaller.GET(path).catch( - BackendAPICaller.handleError('GET', path) - ) + static async getActivePlants(): Promise { + const path: string = 'mission-loader/active-plants' + const result = await BackendAPICaller.GET(path).catch(BackendAPICaller.handleError('GET', path)) return result.content } - static async postMission(echoMissionId: number, robotId: string, installationCode: string | null) { + static async postMission(missionId: string, robotId: string, installationCode: string | null) { const path: string = 'missions' const robots: Robot[] = await BackendAPICaller.getEnabledRobots() const desiredRobot = filterRobots(robots, robotId) const body = { robotId: desiredRobot[0].id, - echoMissionId: echoMissionId, + missionId: missionId, installationCode: installationCode, areaName: '', } diff --git a/frontend/src/components/Contexts/InstallationContext.tsx b/frontend/src/components/Contexts/InstallationContext.tsx index 668aab9d0..0e74f62f5 100644 --- a/frontend/src/components/Contexts/InstallationContext.tsx +++ b/frontend/src/components/Contexts/InstallationContext.tsx @@ -1,6 +1,6 @@ import { createContext, FC, useContext, useState, useEffect } from 'react' import { BackendAPICaller } from 'api/ApiCaller' -import { EchoPlantInfo } from 'models/EchoMission' +import { PlantInfo } from 'models/MissionDefinition' import { Deck } from 'models/Deck' import { SignalREventLabels, useSignalRContext } from './SignalRContext' import { Area } from 'models/Area' @@ -17,10 +17,10 @@ interface IInstallationContext { switchInstallation: (selectedName: string) => void } -const mapInstallationCodeToName = (echoPlantInfoArray: EchoPlantInfo[]): Map => { +const mapInstallationCodeToName = (plantInfoArray: PlantInfo[]): Map => { var mapping = new Map() - echoPlantInfoArray.forEach((echoPlantInfo: EchoPlantInfo) => { - mapping.set(echoPlantInfo.projectDescription, echoPlantInfo.plantCode) + plantInfoArray.forEach((plantInfo: PlantInfo) => { + mapping.set(plantInfo.projectDescription, plantInfo.plantCode) }) return mapping } @@ -53,17 +53,15 @@ export const InstallationProvider: FC = ({ children }) => { const installationCode = (allPlantsMap.get(installationName) || '').toUpperCase() useEffect(() => { - BackendAPICaller.getEchoPlantInfo() - .then((response: EchoPlantInfo[]) => { + BackendAPICaller.getPlantInfo() + .then((response: PlantInfo[]) => { const mapping = mapInstallationCodeToName(response) setAllPlantsMap(mapping) }) .catch((e) => { setAlert( AlertType.RequestFail, - , + , AlertCategory.ERROR ) }) diff --git a/frontend/src/components/Contexts/MissionDefinitionsContext.tsx b/frontend/src/components/Contexts/MissionDefinitionsContext.tsx index e7f73f74f..ae576a737 100644 --- a/frontend/src/components/Contexts/MissionDefinitionsContext.tsx +++ b/frontend/src/components/Contexts/MissionDefinitionsContext.tsx @@ -1,7 +1,7 @@ import { createContext, FC, useContext, useEffect, useState } from 'react' import { BackendAPICaller } from 'api/ApiCaller' import { SignalREventLabels, useSignalRContext } from './SignalRContext' -import { CondensedMissionDefinition } from 'models/MissionDefinition' +import { MissionDefinition } from 'models/MissionDefinition' import { useInstallationContext } from './InstallationContext' import { useLanguageContext } from './LanguageContext' import { AlertType, useAlertContext } from './AlertContext' @@ -9,7 +9,7 @@ import { FailedRequestAlertContent } from 'components/Alerts/FailedRequestAlert' import { AlertCategory } from 'components/Alerts/AlertsBanner' interface IMissionDefinitionsContext { - missionDefinitions: CondensedMissionDefinition[] + missionDefinitions: MissionDefinition[] } interface Props { @@ -22,10 +22,7 @@ const defaultMissionDefinitionsContext: IMissionDefinitionsContext = { export const MissionDefinitionsContext = createContext(defaultMissionDefinitionsContext) -const upsertMissionDefinition = ( - oldQueue: CondensedMissionDefinition[], - updatedMission: CondensedMissionDefinition -) => { +const upsertMissionDefinition = (oldQueue: MissionDefinition[], updatedMission: MissionDefinition) => { const oldQueueCopy = [...oldQueue] const existingIndex = oldQueueCopy.findIndex((m) => m.id === updatedMission.id) if (existingIndex !== -1) { @@ -40,11 +37,10 @@ const fetchMissionDefinitions = (params: { installationCode: string pageSize: number orderBy: string -}): Promise => - BackendAPICaller.getMissionDefinitions(params).then((response) => response.content) +}): Promise => BackendAPICaller.getMissionDefinitions(params).then((response) => response.content) export const useMissionDefinitions = (): IMissionDefinitionsContext => { - const [missionDefinitions, setMissionDefinitions] = useState([]) + const [missionDefinitions, setMissionDefinitions] = useState([]) const { registerEvent, connectionReady } = useSignalRContext() const { installationCode } = useInstallationContext() const { TranslateText } = useLanguageContext() @@ -53,19 +49,19 @@ export const useMissionDefinitions = (): IMissionDefinitionsContext => { useEffect(() => { if (connectionReady) { registerEvent(SignalREventLabels.missionDefinitionUpdated, (username: string, message: string) => { - const missionDefinition: CondensedMissionDefinition = JSON.parse(message) + const missionDefinition: MissionDefinition = JSON.parse(message) setMissionDefinitions((oldMissionDefinitions) => upsertMissionDefinition(oldMissionDefinitions, missionDefinition) ) }) registerEvent(SignalREventLabels.missionDefinitionCreated, (username: string, message: string) => { - const missionDefinition: CondensedMissionDefinition = JSON.parse(message) + const missionDefinition: MissionDefinition = JSON.parse(message) setMissionDefinitions((oldMissionDefinitions) => upsertMissionDefinition(oldMissionDefinitions, missionDefinition) ) }) registerEvent(SignalREventLabels.missionDefinitionDeleted, (username: string, message: string) => { - const mDef: CondensedMissionDefinition = JSON.parse(message) + const mDef: MissionDefinition = JSON.parse(message) if (!mDef.area) return setMissionDefinitions((oldMissionDefs) => { const oldListCopy = [...oldMissionDefs] @@ -99,7 +95,7 @@ export const useMissionDefinitions = (): IMissionDefinitionsContext => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [BackendAPICaller.accessToken, installationCode]) - const [filteredMissionDefinitions, setFilteredMissionDefinitions] = useState([]) + const [filteredMissionDefinitions, setFilteredMissionDefinitions] = useState([]) useEffect(() => { setFilteredMissionDefinitions( diff --git a/frontend/src/components/Pages/AssetSelectionPage/AssetSelectionPage.tsx b/frontend/src/components/Pages/AssetSelectionPage/AssetSelectionPage.tsx index 752908397..7505308f6 100644 --- a/frontend/src/components/Pages/AssetSelectionPage/AssetSelectionPage.tsx +++ b/frontend/src/components/Pages/AssetSelectionPage/AssetSelectionPage.tsx @@ -8,7 +8,7 @@ import styled from 'styled-components' import { useLanguageContext } from 'components/Contexts/LanguageContext' import { useInstallationContext } from 'components/Contexts/InstallationContext' import { BackendAPICaller } from 'api/ApiCaller' -import { EchoPlantInfo } from 'models/EchoMission' +import { PlantInfo } from 'models/MissionDefinition' import { Header } from 'components/Header/Header' import { config } from 'config' import { AlertType, useAlertContext } from 'components/Contexts/AlertContext' @@ -87,11 +87,9 @@ const InstallationPicker = () => { useEffect(() => { if (BackendAPICaller.accessToken) { - const plantPromise = showActivePlants - ? BackendAPICaller.getActivePlants() - : BackendAPICaller.getEchoPlantInfo() + const plantPromise = showActivePlants ? BackendAPICaller.getActivePlants() : BackendAPICaller.getPlantInfo() plantPromise - .then(async (response: EchoPlantInfo[]) => { + .then(async (response: PlantInfo[]) => { const mapping = mapInstallationCodeToName(response) setAllPlantsMap(mapping) }) @@ -99,7 +97,7 @@ const InstallationPicker = () => { setAlert( AlertType.RequestFail, , AlertCategory.ERROR ) @@ -150,10 +148,10 @@ const InstallationPicker = () => { ) } -const mapInstallationCodeToName = (echoPlantInfoArray: EchoPlantInfo[]): Map => { +const mapInstallationCodeToName = (plantInfoArray: PlantInfo[]): Map => { var mapping = new Map() - echoPlantInfoArray.forEach((echoPlantInfo: EchoPlantInfo) => { - mapping.set(echoPlantInfo.projectDescription, echoPlantInfo.plantCode) + plantInfoArray.forEach((plantInfo: PlantInfo) => { + mapping.set(plantInfo.projectDescription, plantInfo.plantCode) }) return mapping } diff --git a/frontend/src/components/Pages/FrontPage/MissionOverview/ScheduleMissionDialog/FetchingMissionsDialog.tsx b/frontend/src/components/Pages/FrontPage/MissionOverview/ScheduleMissionDialog/FetchingMissionsDialog.tsx index a45d9a8bd..c2b5630fa 100644 --- a/frontend/src/components/Pages/FrontPage/MissionOverview/ScheduleMissionDialog/FetchingMissionsDialog.tsx +++ b/frontend/src/components/Pages/FrontPage/MissionOverview/ScheduleMissionDialog/FetchingMissionsDialog.tsx @@ -40,7 +40,7 @@ export const FetchingMissionsDialog = ({ closeDialog }: { closeDialog: () => voi - {TranslateText('Fetching missions from Echo') + '...'} + {TranslateText('Fetching missions') + '...'}