diff --git a/backend/api.test/Mocks/RobotControllerMock.cs b/backend/api.test/Mocks/RobotControllerMock.cs index aa1528365..b70cc1cdf 100644 --- a/backend/api.test/Mocks/RobotControllerMock.cs +++ b/backend/api.test/Mocks/RobotControllerMock.cs @@ -12,6 +12,7 @@ internal class RobotControllerMock public readonly Mock Mock; public readonly Mock RobotModelServiceMock; public readonly Mock RobotServiceMock; + public readonly Mock InstallationServiceMock; public RobotControllerMock() { @@ -20,6 +21,7 @@ public RobotControllerMock() RobotServiceMock = new Mock(); RobotModelServiceMock = new Mock(); AreaServiceMock = new Mock(); + InstallationServiceMock = new Mock(); var mockLoggerController = new Mock>(); @@ -29,7 +31,8 @@ public RobotControllerMock() IsarServiceMock.Object, MissionServiceMock.Object, RobotModelServiceMock.Object, - AreaServiceMock.Object + AreaServiceMock.Object, + InstallationServiceMock.Object ) { CallBase = true diff --git a/backend/api/Controllers/MissionSchedulingController.cs b/backend/api/Controllers/MissionSchedulingController.cs index f1278a071..4822bb6aa 100644 --- a/backend/api/Controllers/MissionSchedulingController.cs +++ b/backend/api/Controllers/MissionSchedulingController.cs @@ -47,6 +47,16 @@ [FromBody] ScheduleMissionQuery scheduledMissionQuery var robot = await robotService.ReadById(scheduledMissionQuery.RobotId); if (robot is null) return NotFound($"Could not find robot with id {scheduledMissionQuery.RobotId}"); + if (!robot.IsRobotPressureHighEnoughToStartMission()) + { + return BadRequest($"The robot pressure on {robot.Name} is too low to start a mission"); + } + + if (!robot.IsRobotBatteryLevelHighEnoughToStartMissions()) + { + return BadRequest($"The robot battery level on {robot.Name} is too low to start a mission"); + } + var missionRun = await missionRunService.ReadById(missionRunId); if (missionRun == null) return NotFound("Mission run not found"); @@ -117,6 +127,16 @@ [FromBody] ScheduleMissionQuery scheduledMissionQuery return NotFound($"Could not find robot with id {scheduledMissionQuery.RobotId}"); } + if (!robot.IsRobotPressureHighEnoughToStartMission()) + { + return BadRequest($"The robot pressure on {robot.Name} is too low to start a mission"); + } + + if (!robot.IsRobotBatteryLevelHighEnoughToStartMissions()) + { + return BadRequest($"The robot battery level on {robot.Name} is too low to start a mission"); + } + var missionDefinition = await missionDefinitionService.ReadById(missionDefinitionId); if (missionDefinition == null) { @@ -183,6 +203,16 @@ [FromBody] ScheduledMissionQuery scheduledMissionQuery return NotFound($"Could not find robot with id {scheduledMissionQuery.RobotId}"); } + if (!robot.IsRobotPressureHighEnoughToStartMission()) + { + return BadRequest($"The robot pressure on {robot.Name} is too low to start a mission"); + } + + if (!robot.IsRobotBatteryLevelHighEnoughToStartMissions()) + { + return BadRequest($"The robot battery level on {robot.Name} is too low to start a mission"); + } + EchoMission? echoMission; try { @@ -335,6 +365,16 @@ [FromBody] CustomMissionQuery customMissionQuery var robot = await robotService.ReadById(customMissionQuery.RobotId); if (robot is null) { return NotFound($"Could not find robot with id {customMissionQuery.RobotId}"); } + if (!robot.IsRobotPressureHighEnoughToStartMission()) + { + return BadRequest($"The robot pressure on {robot.Name} is too low to start a mission"); + } + + if (!robot.IsRobotBatteryLevelHighEnoughToStartMissions()) + { + return BadRequest($"The robot battery level on {robot.Name} is too low to start a mission"); + } + var installation = await installationService.ReadByName(customMissionQuery.InstallationCode); if (installation == null) { return NotFound($"Could not find installation with name {customMissionQuery.InstallationCode}"); } @@ -351,6 +391,7 @@ [FromBody] CustomMissionQuery customMissionQuery MissionRun? newMissionRun; try { newMissionRun = await customMissionSchedulingService.QueueCustomMissionRun(customMissionQuery, customMissionDefinition.Id, robot.Id, missionTasks); } + catch (Exception e) when (e is RobotPressureTooLowException or RobotBatteryLevelTooLowException) { return BadRequest(e.Message); } catch (Exception e) when (e is RobotNotFoundException or MissionNotFoundException) { return NotFound(e.Message); } return CreatedAtAction(nameof(Create), new diff --git a/backend/api/Controllers/Models/UpdateRobotQuery.cs b/backend/api/Controllers/Models/UpdateRobotQuery.cs new file mode 100644 index 000000000..c9bb64ab7 --- /dev/null +++ b/backend/api/Controllers/Models/UpdateRobotQuery.cs @@ -0,0 +1,13 @@ +namespace Api.Database.Models +{ + public class UpdateRobotQuery + { + public string? InstallationId { get; set; } + + public string? AreaId { get; set; } + + public Pose? Pose { get; set; } + + public string? MissionId { get; set; } + } +} diff --git a/backend/api/Controllers/RobotController.cs b/backend/api/Controllers/RobotController.cs index a6b612068..534f72d49 100644 --- a/backend/api/Controllers/RobotController.cs +++ b/backend/api/Controllers/RobotController.cs @@ -10,12 +10,14 @@ namespace Api.Controllers [ApiController] [Route("robots")] public class RobotController( - ILogger logger, - IRobotService robotService, - IIsarService isarService, - IMissionSchedulingService missionSchedulingService, - IRobotModelService robotModelService - ) : ControllerBase + ILogger logger, + IRobotService robotService, + IIsarService isarService, + IMissionSchedulingService missionSchedulingService, + IRobotModelService robotModelService, + IAreaService areaService, + IInstallationService installationService + ) : ControllerBase { /// /// List all robots on the installation. @@ -173,6 +175,91 @@ [FromBody] Robot robot } } + /// + /// Updates a specific field of a robot in the database + /// + /// + /// + /// The robot was successfully updated + /// The robot data is invalid + /// There was no robot with the given ID in the database + /// /// The given field name is not valid + [HttpPut] + [Authorize(Roles = Role.Admin)] + [Route("{id}/{fieldName}")] + [ProducesResponseType(typeof(RobotResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task> UpdateRobotField( + [FromRoute] string id, + [FromRoute] string fieldName, + [FromBody] UpdateRobotQuery query + ) + { + logger.LogInformation("Updating robot with id={Id}", id); + + if (!ModelState.IsValid) + return BadRequest("Invalid data"); + + try + { + var robot = await robotService.ReadById(id); + if (robot == null) + { + string errorMessage = $"No robot with id: {id} could be found"; + logger.LogError("{Message}", errorMessage); + return NotFound(errorMessage); + } + + Robot updatedRobot; + switch (fieldName) + { + case "installationId": + if (query.InstallationId == null) + updatedRobot = await robotService.UpdateCurrentInstallation(id, null); + else + { + var installation = await installationService.ReadById(query.InstallationId); + if (installation == null) return NotFound($"No installation with ID {query.InstallationId} was found"); + updatedRobot = await robotService.UpdateCurrentInstallation(id, installation); + } + break; + case "areaId": + if (query.AreaId == null) + updatedRobot = await robotService.UpdateCurrentArea(id, null); + else + { + var area = await areaService.ReadById(query.AreaId); + if (area == null) return NotFound($"No area with ID {query.AreaId} was found"); + updatedRobot = await robotService.UpdateCurrentArea(id, area); + } + break; + case "pose": + if (query.Pose == null) return BadRequest("Cannot set robot pose to null"); + updatedRobot = await robotService.UpdateRobotPose(id, query.Pose); + break; + case "missionId": + updatedRobot = await robotService.UpdateCurrentMissionId(id, query.MissionId); + break; + default: + return NotFound($"Could not find any field with name {fieldName}"); + } + + var robotResponse = new RobotResponse(updatedRobot); + logger.LogInformation("Successful PUT of robot to database"); + + return Ok(robotResponse); + } + catch (Exception e) + { + logger.LogError(e, "Error while updating robot with id={Id}", id); + throw; + } + } + /// /// Deletes the robot with the specified id from the database /// diff --git a/backend/api/Database/Context/InitDb.cs b/backend/api/Database/Context/InitDb.cs index aa28284c5..ba935e47a 100644 --- a/backend/api/Database/Context/InitDb.cs +++ b/backend/api/Database/Context/InitDb.cs @@ -693,8 +693,8 @@ public static void AddRobotModelsToDatabase(FlotillaDbContext context) new() { Type = type, - BatteryWarningThreshold = 20f, - LowerPressureWarningThreshold = 40f, + BatteryWarningThreshold = 0f, + LowerPressureWarningThreshold = 0f, UpperPressureWarningThreshold = 80f }; context.Add(model); diff --git a/backend/api/Database/Models/Robot.cs b/backend/api/Database/Models/Robot.cs index 84ac3e786..db7633a11 100644 --- a/backend/api/Database/Models/Robot.cs +++ b/backend/api/Database/Models/Robot.cs @@ -73,6 +73,16 @@ public Robot(CreateRobotQuery createQuery, Installation installation, Area? area public float? PressureLevel { get; set; } + public bool IsRobotPressureHighEnoughToStartMission() + { + return Model.LowerPressureWarningThreshold == null || PressureLevel == null || Model.LowerPressureWarningThreshold <= PressureLevel; + } + + public bool IsRobotBatteryLevelHighEnoughToStartMissions() + { + return Model.BatteryWarningThreshold == null || Model.BatteryWarningThreshold <= BatteryLevel; + } + public IList VideoStreams { get; set; } [Required] diff --git a/backend/api/Services/ActionServices/CustomMissionSchedulingService.cs b/backend/api/Services/ActionServices/CustomMissionSchedulingService.cs index 0fba21181..d432a1914 100644 --- a/backend/api/Services/ActionServices/CustomMissionSchedulingService.cs +++ b/backend/api/Services/ActionServices/CustomMissionSchedulingService.cs @@ -93,6 +93,16 @@ public async Task QueueCustomMissionRun(CustomMissionQuery customMis throw new RobotNotFoundException(errorMessage); } + if (!robot.IsRobotPressureHighEnoughToStartMission()) + { + throw new RobotPressureTooLowException($"The robot pressure on {robot.Name} is too low to start a mission"); + } + + if (!robot.IsRobotBatteryLevelHighEnoughToStartMissions()) + { + throw new RobotBatteryLevelTooLowException($"The robot battery level on {robot.Name} is too low to start a mission"); + } + var scheduledMission = new MissionRun { Name = customMissionQuery.Name, diff --git a/backend/api/Services/RobotService.cs b/backend/api/Services/RobotService.cs index b3273e687..9c4696fe3 100644 --- a/backend/api/Services/RobotService.cs +++ b/backend/api/Services/RobotService.cs @@ -25,6 +25,7 @@ public interface IRobotService public Task UpdateCurrentMissionId(string robotId, string? missionId); public Task UpdateCurrentArea(string robotId, Area? area); public Task UpdateMissionQueueFrozen(string robotId, bool missionQueueFrozen); + public Task UpdateCurrentInstallation(string robotId, Installation? installation); public Task Delete(string id); public Task SetRobotOffline(string robotId); } @@ -222,6 +223,24 @@ public async Task UpdateCurrentMissionId(string robotId, string? currentM public async Task UpdateCurrentArea(string robotId, Area? area) { return await UpdateRobotProperty(robotId, "CurrentArea", area); } + public async Task UpdateCurrentInstallation(string robotId, Installation? installation) + { + var robotQuery = context.Robots.Where(robot => robot.Id == robotId).Include(robot => robot.CurrentInstallation); + var robot = await robotQuery.FirstOrDefaultAsync(); + ThrowIfRobotIsNull(robot, robotId); + + await VerifyThatUserIsAuthorizedToUpdateDataForInstallation(robot!.CurrentInstallation); + await VerifyThatUserIsAuthorizedToUpdateDataForInstallation(installation); + + await robotQuery.ExecuteUpdateAsync(robots => robots.SetProperty(r => r.CurrentInstallation, installation)); + + robot = await robotQuery.FirstOrDefaultAsync(); + ThrowIfRobotIsNull(robot, robotId); + NotifySignalROfUpdatedRobot(robot!, robot!.CurrentInstallation!); + + return robot; + } + public async Task UpdateMissionQueueFrozen(string robotId, bool missionQueueFrozen) { var robotQuery = context.Robots.Where(robot => robot.Id == robotId).Include(robot => robot.CurrentInstallation); @@ -320,29 +339,6 @@ public async Task SetRobotOffline(string robotId) catch (RobotNotFoundException) { } } - private async Task UpdateRobotProperty(string robotId, string propertyName, object? value) - { - var robot = await ReadById(robotId); - if (robot is null) - { - string errorMessage = $"Robot with ID {robotId} was not found in the database"; - logger.LogError("{Message}", errorMessage); - throw new RobotNotFoundException(errorMessage); - } - - foreach (var property in typeof(Robot).GetProperties()) - { - if (property.Name == propertyName) - { - logger.LogInformation("Setting {robotName} field {propertyName} from {oldValue} to {NewValue}", robot.Name, propertyName, property.GetValue(robot), value); - property.SetValue(robot, value); - } - } - - robot = await Update(robot); - return robot; - } - private IQueryable GetRobotsWithSubModels() { var accessibleInstallationCodes = accessRoleService.GetAllowedInstallationCodes(); @@ -367,6 +363,29 @@ private IQueryable GetRobotsWithSubModels() #pragma warning restore CA1304 } + private async Task UpdateRobotProperty(string robotId, string propertyName, object? value) + { + var robot = await ReadById(robotId); + if (robot is null) + { + string errorMessage = $"Robot with ID {robotId} was not found in the database"; + logger.LogError("{Message}", errorMessage); + throw new RobotNotFoundException(errorMessage); + } + + foreach (var property in typeof(Robot).GetProperties()) + { + if (property.Name == propertyName) + { + logger.LogInformation("Setting {robotName} field {propertyName} from {oldValue} to {NewValue}", robot.Name, propertyName, property.GetValue(robot), value); + property.SetValue(robot, value); + } + } + + robot = await Update(robot); + return robot; + } + private async Task ApplyDatabaseUpdate(Installation? installation) { var accessibleInstallationCodes = await accessRoleService.GetAllowedInstallationCodes(); diff --git a/backend/api/Utilities/Exceptions.cs b/backend/api/Utilities/Exceptions.cs index 54e85385a..d242ea33e 100644 --- a/backend/api/Utilities/Exceptions.cs +++ b/backend/api/Utilities/Exceptions.cs @@ -70,6 +70,14 @@ public class RobotInformationNotAvailableException(string message) : Exception(m { } + public class RobotPressureTooLowException(string message) : Exception(message) + { + } + + public class RobotBatteryLevelTooLowException(string message) : Exception(message) + { + } + public class TagPositionNotFoundException(string message) : Exception(message) { } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index cd7e7f259..0581fc71b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -32,6 +32,7 @@ "react-router-dom": "^6.11.1", "react-scripts": "^5.0.1", "styled-components": "^5.3.11", + "ts-custom-error": "^3.3.1", "typescript": "^4.7.4", "video.js": "^7.20.3", "web-vitals": "^2.1.4" @@ -14908,6 +14909,14 @@ "version": "1.0.1", "license": "MIT" }, + "node_modules/ts-custom-error": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/ts-custom-error/-/ts-custom-error-3.3.1.tgz", + "integrity": "sha512-5OX1tzOjxWEgsr/YEUWSuPrQ00deKLh6D7OTWcvNHm12/7QPyRh8SYpyWvA4IZv8H/+GQWQEh/kwo95Q9OVW1A==", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "license": "Apache-2.0" diff --git a/frontend/package.json b/frontend/package.json index 7571514e9..90f0dca2b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -28,6 +28,7 @@ "react-router-dom": "^6.11.1", "react-scripts": "^5.0.1", "styled-components": "^5.3.11", + "ts-custom-error": "^3.3.1", "typescript": "^4.7.4", "video.js": "^7.20.3", "web-vitals": "^2.1.4" diff --git a/frontend/src/api/ApiCaller.tsx b/frontend/src/api/ApiCaller.tsx index bb10a8de8..44524793f 100644 --- a/frontend/src/api/ApiCaller.tsx +++ b/frontend/src/api/ApiCaller.tsx @@ -15,6 +15,7 @@ import { CondensedMissionDefinition, EchoMissionDefinition } from 'models/Missio import { EchoMission } from 'models/EchoMission' import { MissionDefinitionUpdateForm } from 'models/MissionDefinitionUpdateForm' import { Deck } from 'models/Deck' +import { ApiError, isApiError } from './ApiError' /** Implements the request sent to the backend api. */ export class BackendAPICaller { @@ -70,7 +71,8 @@ export class BackendAPICaller { response = await fetch(url, initializedRequest) } - if (!response.ok) throw new Error(`${response.status} - ${response.statusText}`) + if (!response.ok) throw ApiError.fromCode(response.status, response.statusText, await response.text()) + var responseContent // Status code 204 means no content if (response.status !== 204) { @@ -87,6 +89,16 @@ export class BackendAPICaller { return { content: responseContent, headers: response.headers } } + private static handleError = (requestType: string, path: string) => (e: Error) => { + if (isApiError(e)) { + console.error(`Failed to ${requestType} /${path}: ` + (e as ApiError).logMessage) + throw new Error((e as ApiError).message) + } + + console.error(`Failed to ${requestType} /${path}: ` + e) + throw e + } + private static async GET( path: string, contentType?: string @@ -116,36 +128,24 @@ export class BackendAPICaller { private static async postControlMissionRequest(path: string, robotId: string): Promise { const body = { robotId: robotId } - await BackendAPICaller.POST(path, body).catch((e) => { - console.error(`Failed to POST /${path}: ` + e) - throw e - }) + await BackendAPICaller.POST(path, body).catch(BackendAPICaller.handleError('POST', path)) } static async getEnabledRobots(): Promise { const path: string = 'robots' - const result = await BackendAPICaller.GET(path).catch((e) => { - console.error(`Failed to GET /${path}: ` + e) - throw e - }) + const result = await BackendAPICaller.GET(path).catch(BackendAPICaller.handleError('GET', path)) return result.content.filter((robot) => robot.enabled) } static async getRobotById(robotId: string): Promise { const path: string = 'robots/' + robotId - const result = await this.GET(path).catch((e) => { - console.error(`Failed to GET /${path}: ` + e) - throw e - }) + const result = await this.GET(path).catch(BackendAPICaller.handleError('GET', path)) return result.content } static async getAllEchoMissions(): Promise { const path: string = 'echo/missions' - const result = await BackendAPICaller.GET(path).catch((e) => { - console.error(`Failed to GET /${path}: ` + e) - throw e - }) + const result = await BackendAPICaller.GET(path).catch(BackendAPICaller.handleError('GET', path)) return result.content } @@ -185,10 +185,7 @@ export class BackendAPICaller { if (parameters.minDesiredStartTime) path = path + 'MinDesiredStartTime=' + parameters.minDesiredStartTime + '&' if (parameters.maxDesiredStartTime) path = path + 'MaxDesiredStartTime=' + parameters.maxDesiredStartTime + '&' - const result = await BackendAPICaller.GET(path).catch((e) => { - console.error(`Failed to GET /${path}: ` + e) - throw e - }) + const result = await BackendAPICaller.GET(path).catch(BackendAPICaller.handleError('GET', path)) if (!result.headers.has(PaginationHeaderName)) { console.error('No Pagination header received ("' + PaginationHeaderName + '")') } @@ -198,10 +195,9 @@ export class BackendAPICaller { static async getAvailableEchoMissions(installationCode: string = ''): Promise { const path: string = 'echo/available-missions/' + installationCode - const result = await BackendAPICaller.GET(path).catch((e) => { - console.error(`Failed to GET /${path}: ` + e) - throw e - }) + const result = await BackendAPICaller.GET(path).catch( + BackendAPICaller.handleError('GET', path) + ) return result.content } @@ -222,10 +218,9 @@ export class BackendAPICaller { if (parameters.nameSearch) path = path + 'NameSearch=' + parameters.nameSearch + '&' if (parameters.sourceType) path = path + 'SourceType=' + parameters.sourceType + '&' - const result = await BackendAPICaller.GET(path).catch((e) => { - console.error(`Failed to GET /${path}: ` + e) - throw e - }) + const result = await BackendAPICaller.GET(path).catch( + BackendAPICaller.handleError('GET', path) + ) if (!result.headers.has(PaginationHeaderName)) { console.error('No Pagination header received ("' + PaginationHeaderName + '")') } @@ -236,20 +231,18 @@ export class BackendAPICaller { static async getMissionDefinitionsInArea(area: Area): Promise { let path: string = 'areas/' + area.id + '/mission-definitions' - const result = await BackendAPICaller.GET(path).catch((e) => { - console.error(`Failed to GET /${path}: ` + e) - throw e - }) + const result = await BackendAPICaller.GET(path).catch( + BackendAPICaller.handleError('GET', path) + ) return result.content } static async getMissionDefinitionsInDeck(deck: Deck): Promise { let path: string = 'decks/' + deck.id + '/mission-definitions' - const result = await BackendAPICaller.GET(path).catch((e) => { - console.error(`Failed to GET /${path}: ` + e) - throw e - }) + const result = await BackendAPICaller.GET(path).catch( + BackendAPICaller.handleError('GET', path) + ) return result.content } @@ -261,63 +254,48 @@ export class BackendAPICaller { const result = await BackendAPICaller.PUT( path, form - ).catch((e) => { - console.error(`Failed to PUT /${path}: ` + e) - throw e - }) + ).catch(BackendAPICaller.handleError('PUT', path)) return result.content } static async deleteMissionDefinition(id: string) { const path: string = 'missions/definitions/' + id - await BackendAPICaller.DELETE(path, '').catch((e) => { - console.error(`Failed to DELETE /${path}: ` + e) - throw e - }) + 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((e) => { - console.error(`Failed to GET /${path}: ` + e) - throw e - }) + const result = await BackendAPICaller.GET(path).catch( + BackendAPICaller.handleError('GET', path) + ) return result.content } static async getMissionRunById(missionId: string): Promise { const path: string = 'missions/runs/' + missionId - const result = await BackendAPICaller.GET(path).catch((e) => { - console.error(`Failed to GET /${path}: ` + e) - throw e - }) + const result = await BackendAPICaller.GET(path).catch(BackendAPICaller.handleError('GET', path)) return result.content } static async getVideoStreamsByRobotId(robotId: string): Promise { const path: string = 'robots/' + robotId + '/video-streams' - const result = await BackendAPICaller.GET(path).catch((e) => { - console.error(`Failed to GET /${path}: ` + e) - throw e - }) + const result = await BackendAPICaller.GET(path).catch(BackendAPICaller.handleError('GET', path)) return result.content } static async getEchoPlantInfo(): Promise { const path: string = 'echo/plants' - const result = await BackendAPICaller.GET(path).catch((e: Error) => { - console.error(`Failed to GET /${path}: ` + e) - throw e - }) + 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((e: Error) => { - console.error(`Failed to GET /${path}: ` + e) - throw e - }) + const result = await BackendAPICaller.GET(path).catch( + BackendAPICaller.handleError('GET', path) + ) return result.content } @@ -332,10 +310,9 @@ export class BackendAPICaller { installationCode: installationCode, areaName: '', } - const result = await BackendAPICaller.POST(path, body).catch((e) => { - console.error(`Failed to POST /${path}: ` + e) - throw e - }) + const result = await BackendAPICaller.POST(path, body).catch( + BackendAPICaller.handleError('POST', path) + ) return result.content } @@ -346,51 +323,41 @@ export class BackendAPICaller { const body = { robotId: desiredRobot[0].id, } - const result = await BackendAPICaller.POST(path, body).catch((e) => { - console.error(`Failed to POST /${path}: ` + e) - throw e - }) + const result = await BackendAPICaller.POST(path, body).catch( + BackendAPICaller.handleError('POST', path) + ) return result.content } static async setArmPosition(robotId: string, armPosition: string): Promise { const path: string = `robots/${robotId}/SetArmPosition/${armPosition}` - await BackendAPICaller.PUT(path).catch((e) => { - console.error(`Failed to PUT /${path}: ` + e) - throw e - }) + await BackendAPICaller.PUT(path).catch(BackendAPICaller.handleError('PUT', path)) } static async deleteMission(missionId: string) { const path: string = 'missions/runs/' + missionId - return await BackendAPICaller.DELETE(path, '').catch((e) => { - console.error(`Failed to DELETE /${path}: ` + e) - throw e - }) + return await BackendAPICaller.DELETE(path, '').catch(BackendAPICaller.handleError('DELETE', path)) } static async pauseMission(robotId: string): Promise { const path: string = 'robots/' + robotId + '/pause' - return BackendAPICaller.postControlMissionRequest(path, robotId).catch((e) => { - console.error(`Failed to POST /${path}: ` + e) - throw e - }) + return BackendAPICaller.postControlMissionRequest(path, robotId).catch( + BackendAPICaller.handleError('POST', path) + ) } static async resumeMission(robotId: string): Promise { const path: string = 'robots/' + robotId + '/resume' - return BackendAPICaller.postControlMissionRequest(path, robotId).catch((e) => { - console.error(`Failed to POST /${path}: ` + e) - throw e - }) + return BackendAPICaller.postControlMissionRequest(path, robotId).catch( + BackendAPICaller.handleError('POST', path) + ) } static async stopMission(robotId: string): Promise { const path: string = 'robots/' + robotId + '/stop' - return BackendAPICaller.postControlMissionRequest(path, robotId).catch((e) => { - console.error(`Failed to POST /${path}: ` + e) - throw e - }) + return BackendAPICaller.postControlMissionRequest(path, robotId).catch( + BackendAPICaller.handleError('POST', path) + ) } static async getMap(installationCode: string, mapName: string): Promise { @@ -398,36 +365,24 @@ export class BackendAPICaller { return BackendAPICaller.GET(path, 'image/png') .then((response) => response.content) - .catch((e) => { - console.error(`Failed to GET /${path}: ` + e) - throw e - }) + .catch(BackendAPICaller.handleError('GET', path)) } static async getAreas(): Promise { const path: string = 'areas' - const result = await this.GET(path).catch((e) => { - console.error(`Failed to GET /${path}: ` + e) - throw e - }) + const result = await this.GET(path).catch(BackendAPICaller.handleError('GET', path)) return result.content } static async getAreasByDeckId(deckId: string): Promise { const path: string = 'areas/deck/' + deckId - const result = await this.GET(path).catch((e) => { - console.error(`Failed to GET /${path}: ` + e) - throw e - }) + const result = await this.GET(path).catch(BackendAPICaller.handleError('GET', path)) return result.content } static async getDecks(): Promise { const path: string = 'decks' - const result = await this.GET(path).catch((e) => { - console.error(`Failed to GET /${path}: ` + e) - throw e - }) + const result = await this.GET(path).catch(BackendAPICaller.handleError('GET', path)) return result.content } @@ -471,10 +426,7 @@ export class BackendAPICaller { const path: string = `emergency-action/${installationCode}/abort-current-missions-and-send-all-robots-to-safe-zone` const body = {} - const result = await this.POST(path, body).catch((e) => { - console.error(`Failed to POST /${path}: ` + e) - throw e - }) + const result = await this.POST(path, body).catch(BackendAPICaller.handleError('POST', path)) return result.content } @@ -482,10 +434,7 @@ export class BackendAPICaller { const path: string = `emergency-action/${installationCode}/clear-emergency-state` const body = {} - const result = await this.POST(path, body).catch((e) => { - console.error(`Failed to POST /${path}: ` + e) - throw e - }) + const result = await this.POST(path, body).catch(BackendAPICaller.handleError('POST', path)) return result.content } } diff --git a/frontend/src/api/ApiError.tsx b/frontend/src/api/ApiError.tsx new file mode 100644 index 000000000..4cb02a396 --- /dev/null +++ b/frontend/src/api/ApiError.tsx @@ -0,0 +1,70 @@ +import { CustomError } from 'ts-custom-error' + +export const StatusTexts: { [key: number]: string } = { + 400: 'Bad Request', + 401: 'Unauthorized', // RFC 7235 + 402: 'Payment Required', + 403: 'Forbidden', + 404: 'Not Found', + 405: 'Method Not Allowed', + 406: 'Not Acceptable', + 407: 'Proxy Authentication Required', // RFC 7235 + 408: 'Request Timeout', + 409: 'Conflict', + 410: 'Gone', + 411: 'Length Required', + 412: 'Precondition Failed', // RFC 7232 + 413: 'Payload Too Large', // RFC 7231 + 414: 'URI Too Long', // RFC 7231 + 415: 'Unsupported Media Type', + 416: 'Range Not Satisfiable', // RFC 7233 + 417: 'Expectation Failed', + 418: "I'm a teapot", // RFC 2324 + 421: 'Misdirected Request', // RFC 7540 + 426: 'Upgrade Required', + 428: 'Precondition Required', // RFC 6585 + 429: 'Too Many Requests', // RFC 6585 + 431: 'Request Header Fields Too Large', // RFC 6585 + 451: 'Unavailable For Legal Reasons', // RFC 7725 + 500: 'Internal Server Error', + 501: 'Not Implemented', + 502: 'Bad Gateway', + 503: 'Service Unavailable', + 504: 'Gateway Timeout', + 505: 'HTTP Version Not Supported', + 506: 'Variant Also Negotiates', // RFC 2295 + 510: 'Not Extended', // RFC 2774 + 511: 'Network Authentication Required', // RFC 6585 +} as const + +/** + * Api error + * + * Usage: throw ApiError.fromCode(404) + */ +export class ApiError extends CustomError { + public constructor( + public statusCode: number, + public message: string, + public logMessage: string + ) { + super(logMessage) + } + + public static fromCode(code: number, statusText?: string, message?: string) { + if (!Object.keys(StatusTexts).includes(String(code))) code = 400 + + statusText = statusText ?? StatusTexts[code] + if (code >= 400 && code < 500) return new ApiError(code, message ?? statusText, `${code} - ${statusText}`) + else + return new ApiError( + code, + 'An unexpected error occured when handling the request', + `${code} - ${statusText}` + ) + } +} + +export function isApiError(err: any): err is ApiError { + return err instanceof ApiError +} diff --git a/frontend/src/components/Alerts/FailedRequestAlert.tsx b/frontend/src/components/Alerts/FailedRequestAlert.tsx index 4551de6f8..60b3f778d 100644 --- a/frontend/src/components/Alerts/FailedRequestAlert.tsx +++ b/frontend/src/components/Alerts/FailedRequestAlert.tsx @@ -18,7 +18,7 @@ const Indent = styled.div` padding: 0px 9px; ` -export const FailedRequestAlertContent = ({ message }: { message: string }) => { +export const FailedRequestAlertContent = ({ translatedMessage }: { translatedMessage: string }) => { const { TranslateText } = useLanguageContext() return ( @@ -28,7 +28,7 @@ export const FailedRequestAlertContent = ({ message }: { message: string }) => { diff --git a/frontend/src/components/Displays/MissionButtons/MissionRestartButton.tsx b/frontend/src/components/Displays/MissionButtons/MissionRestartButton.tsx index cd9932f43..acec7088a 100644 --- a/frontend/src/components/Displays/MissionButtons/MissionRestartButton.tsx +++ b/frontend/src/components/Displays/MissionButtons/MissionRestartButton.tsx @@ -48,7 +48,7 @@ export const MissionRestartButton = ({ mission, hasFailedTasks }: MissionProps) .catch(() => setAlert( AlertType.RequestFail, - + ) ) setIsLocationVerificationOpen(false) diff --git a/frontend/src/components/Pages/FrontPage/MissionOverview/MissionQueueView.tsx b/frontend/src/components/Pages/FrontPage/MissionOverview/MissionQueueView.tsx index b5cebf88c..44c58ab80 100644 --- a/frontend/src/components/Pages/FrontPage/MissionOverview/MissionQueueView.tsx +++ b/frontend/src/components/Pages/FrontPage/MissionOverview/MissionQueueView.tsx @@ -38,7 +38,7 @@ export const MissionQueueView = (): JSX.Element => { BackendAPICaller.deleteMission(mission.id).catch((_) => setAlert( AlertType.RequestFail, - + ) ) diff --git a/frontend/src/components/Pages/FrontPage/MissionOverview/ScheduleMissionDialog/SelectMissionsToScheduleDialog.tsx b/frontend/src/components/Pages/FrontPage/MissionOverview/ScheduleMissionDialog/SelectMissionsToScheduleDialog.tsx index 73dfa03ac..1103c1a48 100644 --- a/frontend/src/components/Pages/FrontPage/MissionOverview/ScheduleMissionDialog/SelectMissionsToScheduleDialog.tsx +++ b/frontend/src/components/Pages/FrontPage/MissionOverview/ScheduleMissionDialog/SelectMissionsToScheduleDialog.tsx @@ -51,10 +51,14 @@ export const SelectMissionsToScheduleDialog = ({ echoMissionsList, closeDialog } if (!selectedRobot) return selectedEchoMissions.forEach((mission: EchoMissionDefinition) => { - BackendAPICaller.postMission(mission.echoMissionId, selectedRobot.id, installationCode).catch(() => { + BackendAPICaller.postMission(mission.echoMissionId, selectedRobot.id, installationCode).catch((e) => { setAlert( AlertType.RequestFail, - + ) setLoadingMissionSet((currentSet: Set) => { const updatedSet: Set = new Set(currentSet) diff --git a/frontend/src/components/Pages/InspectionPage/InspectionOverview.tsx b/frontend/src/components/Pages/InspectionPage/InspectionOverview.tsx index ff58e5ec2..441618d6b 100644 --- a/frontend/src/components/Pages/InspectionPage/InspectionOverview.tsx +++ b/frontend/src/components/Pages/InspectionPage/InspectionOverview.tsx @@ -77,7 +77,7 @@ export const InspectionOverviewSection = () => { .catch((_) => { setAlert( AlertType.RequestFail, - + ) setIsFetchingEchoMissions(false) }) diff --git a/frontend/src/components/Pages/InspectionPage/ScheduleMissionDialogs.tsx b/frontend/src/components/Pages/InspectionPage/ScheduleMissionDialogs.tsx index bd6795104..9e05083e5 100644 --- a/frontend/src/components/Pages/InspectionPage/ScheduleMissionDialogs.tsx +++ b/frontend/src/components/Pages/InspectionPage/ScheduleMissionDialogs.tsx @@ -84,10 +84,14 @@ export const ScheduleMissionDialog = (props: IProps): JSX.Element => { if (!selectedRobot || !missionsToSchedule) return missionsToSchedule.forEach((mission) => { - BackendAPICaller.scheduleMissionDefinition(mission.id, selectedRobot.id).catch(() => { + BackendAPICaller.scheduleMissionDefinition(mission.id, selectedRobot.id).catch((e) => { setAlert( AlertType.RequestFail, - + ) setLoadingMissionSet((currentSet: Set) => { const updatedSet: Set = new Set(currentSet) diff --git a/frontend/src/language/en.json b/frontend/src/language/en.json index 5fe1e60d5..6db721f49 100644 --- a/frontend/src/language/en.json +++ b/frontend/src/language/en.json @@ -224,5 +224,6 @@ "which is NOT within the specified range": "which is NOT within the specified range", "The current battery level is": "The current battery level is", "which is above the specified minimum": "which is above the specified minimum", - "Failed to schedule mission ": "Failed to schedule mission " + "which is above the suggested minimum": "which is above the suggested minimum", + "Failed to schedule mission": "Failed to schedule mission" } diff --git a/frontend/src/language/no.json b/frontend/src/language/no.json index db6be5859..9fa2d29c7 100644 --- a/frontend/src/language/no.json +++ b/frontend/src/language/no.json @@ -224,5 +224,6 @@ "which is NOT within the specified range": "som IKKE er tilfredstillende", "The current battery level is": "Batterinivået er", "which is above the specified minimum": "som er høyere enn anbefalt minimum", - "Failed to schedule mission ": "Kunne ikke planlegge oppdrag " + "which is above the suggested minimum": "som er mer enn anbefalt minimum", + "Failed to schedule mission": "Kunne ikke legge til oppdrag" }