diff --git a/backend/api.test/Controllers/StatisticsControllerTests.cs b/backend/api.test/Controllers/StatisticsControllerTests.cs new file mode 100644 index 000000000..1133f8518 --- /dev/null +++ b/backend/api.test/Controllers/StatisticsControllerTests.cs @@ -0,0 +1,289 @@ +using System; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json; +using System.Threading.Tasks; +using Api.Controllers.Models; +using Api.Database.Context; +using Api.Database.Models; +using Api.Test.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Testcontainers.PostgreSql; +using Xunit; +using TaskStatus = Api.Database.Models.TaskStatus; + +namespace Api.Test.Controllers +{ + public class StatisticsControllerTests : IAsyncLifetime + { + private const long SecondsPerHour = 3600; + private const long SecondsPerDay = 24 * SecondsPerHour; + private const long SecondsPerWeek = 7 * SecondsPerDay; + + public required DatabaseUtilities DatabaseUtilities; + public required PostgreSqlContainer Container; + public required string ConnectionString; + public required HttpClient Client; + public required JsonSerializerOptions SerializerOptions; + public required FlotillaDbContext Context; + + public async ValueTask InitializeAsync() + { + (Container, ConnectionString, var connection) = + await TestSetupHelpers.ConfigurePostgreSqlDatabase(); + var factory = TestSetupHelpers.ConfigureWebApplicationFactory( + postgreSqlConnectionString: ConnectionString + ); + var serviceProvider = TestSetupHelpers.ConfigureServiceProvider(factory); + + Client = TestSetupHelpers.ConfigureHttpClient(factory); + SerializerOptions = TestSetupHelpers.ConfigureJsonSerializerOptions(); + Context = TestSetupHelpers.ConfigurePostgreSqlContext(ConnectionString); + + DatabaseUtilities = serviceProvider.GetRequiredService(); + } + + public async ValueTask DisposeAsync() + { + GC.SuppressFinalize(this); + await Task.CompletedTask; + } + + private async Task<(Installation, InspectionArea, Robot)> SetupInfrastructure() + { + var installation = await DatabaseUtilities.NewInstallation(); + var plant = await DatabaseUtilities.NewPlant(installation.InstallationCode); + var inspectionArea = await DatabaseUtilities.NewInspectionArea( + installation.InstallationCode, + plant.PlantCode + ); + var robot = await DatabaseUtilities.NewRobot(RobotStatus.Available, installation); + return (installation, inspectionArea, robot); + } + + private Task CreateRun( + Installation installation, + Robot robot, + InspectionArea inspectionArea, + MissionStatus status, + MissionTask[]? tasks = null + ) => + DatabaseUtilities.NewMissionRun( + installation.InstallationCode, + robot, + inspectionArea, + writeToDatabase: true, + missionStatus: status, + tasks: tasks ?? [] + ); + + private static MissionTask NewTask(TaskStatus status, int order) => + new() + { + TagId = $"tag-{order}", + Description = "Task", + RobotPose = new Pose(), + Status = status, + TaskOrder = order, + }; + + private static DateTime DaysAgo(int days) => DateTime.UtcNow.AddDays(-days); + + private async Task SetCreationTime(string missionRunId, DateTime creationTime) + { + await Context + .MissionRuns.Where(m => m.Id == missionRunId) + .ExecuteUpdateAsync(setters => + setters.SetProperty(m => m.CreationTime, creationTime) + ); + } + + private async Task GetStatistics( + string robotId, + long minCreationTime, + long maxCreationTime + ) + { + var response = await Client.GetAsync( + $"statistics/robots/{robotId}/missions?minCreationTime={minCreationTime}&maxCreationTime={maxCreationTime}", + TestContext.Current.CancellationToken + ); + response.EnsureSuccessStatusCode(); + var statistics = await response.Content.ReadFromJsonAsync( + SerializerOptions, + cancellationToken: TestContext.Current.CancellationToken + ); + return statistics!; + } + + [Fact] + public async Task GetRobotMissionStatistics_CountsCompletedRunsAndSuccessRate() + { + var (installation, inspectionArea, robot) = await SetupInfrastructure(); + await CreateRun(installation, robot, inspectionArea, MissionStatus.Successful); + await CreateRun(installation, robot, inspectionArea, MissionStatus.PartiallySuccessful); + await CreateRun(installation, robot, inspectionArea, MissionStatus.Failed); + await CreateRun(installation, robot, inspectionArea, MissionStatus.Aborted); + await CreateRun(installation, robot, inspectionArea, MissionStatus.Cancelled); + // In-flight run must be excluded from the completed-run counts. + await CreateRun(installation, robot, inspectionArea, MissionStatus.Queued); + + long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var statistics = await GetStatistics( + robot.Id, + now - SecondsPerDay, + now + SecondsPerDay + ); + + Assert.Equal(5, statistics.Missions.Total); + Assert.Equal(1, statistics.Missions.Successful); + Assert.Equal(1, statistics.Missions.PartiallySuccessful); + Assert.Equal(1, statistics.Missions.Failed); + Assert.Equal(0.4, statistics.Missions.SuccessRate, 3); + } + + [Fact] + public async Task GetRobotMissionStatistics_AggregatesTaskCountsForCompletedRuns() + { + var (installation, inspectionArea, robot) = await SetupInfrastructure(); + await CreateRun( + installation, + robot, + inspectionArea, + MissionStatus.Successful, + [ + NewTask(TaskStatus.Successful, 0), + NewTask(TaskStatus.Successful, 1), + NewTask(TaskStatus.PartiallySuccessful, 2), + NewTask(TaskStatus.Failed, 3), + ] + ); + // Tasks belonging to an in-flight run must not be counted. + await CreateRun( + installation, + robot, + inspectionArea, + MissionStatus.Queued, + [NewTask(TaskStatus.Successful, 0)] + ); + + long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var statistics = await GetStatistics( + robot.Id, + now - SecondsPerDay, + now + SecondsPerDay + ); + + Assert.Equal(4, statistics.Tasks.Total); + Assert.Equal(2, statistics.Tasks.Successful); + Assert.Equal(1, statistics.Tasks.PartiallySuccessful); + Assert.Equal(0.75, statistics.Tasks.SuccessRate, 3); + } + + [Fact] + public async Task GetRobotMissionStatistics_ExcludesRunsOutsideTimeWindow() + { + var (installation, inspectionArea, robot) = await SetupInfrastructure(); + var insideRun = await CreateRun( + installation, + robot, + inspectionArea, + MissionStatus.Successful + ); + var outsideRun = await CreateRun( + installation, + robot, + inspectionArea, + MissionStatus.Successful + ); + await SetCreationTime(insideRun.Id, DaysAgo(1)); + await SetCreationTime(outsideRun.Id, DaysAgo(10)); + + long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var statistics = await GetStatistics( + robot.Id, + now - 2 * SecondsPerDay, + now + SecondsPerDay + ); + + Assert.Equal(1, statistics.Missions.Total); + } + + [Fact] + public async Task GetRobotMissionStatistics_GroupsCompletedRunsIntoWeeklyBuckets() + { + var (installation, inspectionArea, robot) = await SetupInfrastructure(); + var thisWeek = await CreateRun( + installation, + robot, + inspectionArea, + MissionStatus.Successful + ); + var lastWeek = await CreateRun( + installation, + robot, + inspectionArea, + MissionStatus.Successful + ); + var twoWeeksAgo = await CreateRun( + installation, + robot, + inspectionArea, + MissionStatus.Successful + ); + await SetCreationTime(thisWeek.Id, DaysAgo(2)); + await SetCreationTime(lastWeek.Id, DaysAgo(9)); + await SetCreationTime(twoWeeksAgo.Id, DaysAgo(16)); + + long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var statistics = await GetStatistics(robot.Id, now - 4 * SecondsPerWeek, now); + + // Buckets are oldest-first; the last covers the most recent week. + Assert.Equal(4, statistics.MissionsPerWeek.Count); + Assert.Equal(0, statistics.MissionsPerWeek[0].Count); + Assert.Equal(1, statistics.MissionsPerWeek[1].Count); + Assert.Equal(1, statistics.MissionsPerWeek[2].Count); + Assert.Equal(1, statistics.MissionsPerWeek[3].Count); + } + + [Fact] + public async Task GetRobotMissionStatistics_WithNoRuns_ReturnsZeroedStatistics() + { + var (_, _, robot) = await SetupInfrastructure(); + + long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var statistics = await GetStatistics(robot.Id, now - 2 * SecondsPerWeek, now); + + Assert.Equal(0, statistics.Missions.Total); + Assert.Equal(0, statistics.Tasks.Total); + Assert.Equal(0, statistics.Missions.SuccessRate); + Assert.Equal(2, statistics.MissionsPerWeek.Count); + } + + [Fact] + public async Task GetRobotMissionStatistics_WithoutTimeParameters_ReturnsBadRequest() + { + var response = await Client.GetAsync( + "statistics/robots/any-robot/missions", + TestContext.Current.CancellationToken + ); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task GetRobotMissionStatistics_WithMaxBeforeMin_ReturnsBadRequest() + { + long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var response = await Client.GetAsync( + $"statistics/robots/any-robot/missions?minCreationTime={now}&maxCreationTime={now - SecondsPerHour}", + TestContext.Current.CancellationToken + ); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + } +} diff --git a/backend/api/Controllers/Models/MissionStatisticsResponse.cs b/backend/api/Controllers/Models/MissionStatisticsResponse.cs new file mode 100644 index 000000000..5462a1445 --- /dev/null +++ b/backend/api/Controllers/Models/MissionStatisticsResponse.cs @@ -0,0 +1,28 @@ +namespace Api.Controllers.Models +{ + public class MissionStatisticsResponse + { + /// + /// Completed mission runs in the time window (Successful, + /// PartiallySuccessful, Failed, Aborted or Cancelled). In-flight runs + /// (Pending, Ongoing, Paused, Queued) are excluded. + /// + public int Total { get; set; } + + public int Successful { get; set; } + + public int PartiallySuccessful { get; set; } + + public int Failed { get; set; } + + public int Aborted { get; set; } + + public int Cancelled { get; set; } + + /// + /// Fraction (0-1) of completed runs that were Successful or + /// PartiallySuccessful. + /// + public double SuccessRate { get; set; } + } +} diff --git a/backend/api/Controllers/Models/RobotStatisticsResponse.cs b/backend/api/Controllers/Models/RobotStatisticsResponse.cs new file mode 100644 index 000000000..8ed4fac2a --- /dev/null +++ b/backend/api/Controllers/Models/RobotStatisticsResponse.cs @@ -0,0 +1,17 @@ +namespace Api.Controllers.Models +{ + public class RobotStatisticsResponse + { + public string RobotId { get; set; } = string.Empty; + + public DateTime FromTime { get; set; } + + public DateTime ToTime { get; set; } + + public MissionStatisticsResponse Missions { get; set; } = new(); + + public TaskStatisticsResponse Tasks { get; set; } = new(); + + public IList MissionsPerWeek { get; set; } = []; + } +} diff --git a/backend/api/Controllers/Models/TaskStatisticsResponse.cs b/backend/api/Controllers/Models/TaskStatisticsResponse.cs new file mode 100644 index 000000000..eb0a01fbd --- /dev/null +++ b/backend/api/Controllers/Models/TaskStatisticsResponse.cs @@ -0,0 +1,19 @@ +namespace Api.Controllers.Models +{ + public class TaskStatisticsResponse + { + /// + /// All tasks belonging to the completed mission runs in the window. + /// + public int Total { get; set; } + + public int Successful { get; set; } + + public int PartiallySuccessful { get; set; } + + /// + /// Fraction (0-1) of tasks that were Successful or PartiallySuccessful. + /// + public double SuccessRate { get; set; } + } +} diff --git a/backend/api/Controllers/Models/WeeklyMissionCountResponse.cs b/backend/api/Controllers/Models/WeeklyMissionCountResponse.cs new file mode 100644 index 000000000..f99feb2b7 --- /dev/null +++ b/backend/api/Controllers/Models/WeeklyMissionCountResponse.cs @@ -0,0 +1,14 @@ +namespace Api.Controllers.Models +{ + public class WeeklyMissionCountResponse + { + public DateTime WeekStart { get; set; } + + public DateTime WeekEnd { get; set; } + + /// + /// Completed mission runs with a creation time in [WeekStart, WeekEnd). + /// + public int Count { get; set; } + } +} diff --git a/backend/api/Controllers/StatisticsController.cs b/backend/api/Controllers/StatisticsController.cs new file mode 100644 index 000000000..5494dacde --- /dev/null +++ b/backend/api/Controllers/StatisticsController.cs @@ -0,0 +1,51 @@ +using Api.Controllers.Models; +using Api.Services; +using Api.Utilities; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Api.Controllers +{ + [ApiController] + [Route("statistics")] + public class StatisticsController(IStatisticsService statisticsService) : ControllerBase + { + /// + /// Get aggregated mission and task statistics for a single robot. + /// + /// + /// Counts only completed mission runs whose creation time is in the + /// [minCreationTime, maxCreationTime) window and returns per-week + /// mission counts for the full weeks within that window. Results are + /// limited to installations the requesting user may read. + /// + [HttpGet("robots/{robotId}/missions")] + [Authorize(Roles = Role.Any)] + [ProducesResponseType(typeof(RobotStatisticsResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task> GetRobotMissionStatistics( + [FromRoute] string robotId, + [FromQuery] long? minCreationTime, + [FromQuery] long? maxCreationTime + ) + { + if (minCreationTime is null || maxCreationTime is null) + { + return BadRequest("Both minCreationTime and maxCreationTime must be provided"); + } + if (maxCreationTime < minCreationTime) + { + return BadRequest("Max CreationTime cannot be less than min CreationTime"); + } + + var fromTime = DateTimeUtilities.UnixTimeStampToDateTime(minCreationTime.Value); + var toTime = DateTimeUtilities.UnixTimeStampToDateTime(maxCreationTime.Value); + + var statistics = await statisticsService.GetRobotStatistics(robotId, fromTime, toTime); + return Ok(statistics); + } + } +} diff --git a/backend/api/Program.cs b/backend/api/Program.cs index 660d62097..a014b5d72 100644 --- a/backend/api/Program.cs +++ b/backend/api/Program.cs @@ -97,6 +97,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); bool useInMemoryDatabase = builder .Configuration.GetSection("Database") diff --git a/backend/api/Services/StatisticsService.cs b/backend/api/Services/StatisticsService.cs new file mode 100644 index 000000000..d5903ae9e --- /dev/null +++ b/backend/api/Services/StatisticsService.cs @@ -0,0 +1,140 @@ +using Api.Controllers.Models; +using Api.Database.Context; +using Api.Database.Models; +using Microsoft.EntityFrameworkCore; +using TaskStatus = Api.Database.Models.TaskStatus; + +namespace Api.Services +{ + public interface IStatisticsService + { + public Task GetRobotStatistics( + string robotId, + DateTime fromTime, + DateTime toTime + ); + } + + public class StatisticsService(FlotillaDbContext context, IAccessRoleService accessRoleService) + : IStatisticsService + { + private static readonly MissionStatus[] CompletedMissionStatuses = + [ + MissionStatus.Successful, + MissionStatus.PartiallySuccessful, + MissionStatus.Failed, + MissionStatus.Aborted, + MissionStatus.Cancelled, + ]; + + public async Task GetRobotStatistics( + string robotId, + DateTime fromTime, + DateTime toTime + ) + { + var accessibleInstallationCodes = await accessRoleService.GetAllowedInstallationCodes( + AccessMode.Read + ); + + var completedRuns = context + .MissionRuns.AsNoTracking() + .Where(m => m.Robot.Id == robotId) + .Where(m => m.IsDeprecated == false) + .Where(m => m.CreationTime >= fromTime && m.CreationTime < toTime) + .Where(m => CompletedMissionStatuses.Contains(m.Status)) + .Where(m => + accessibleInstallationCodes.Contains( + m.InspectionArea.Installation.InstallationCode.ToUpper() + ) + ); + + // A single robot completes at most a few hundred runs in a typical + // window, so materialising the status/creation-time pairs keeps the + // per-status counts and weekly buckets in memory cheap. + var runData = await completedRuns + .Select(m => new { m.Status, m.CreationTime }) + .ToListAsync(); + + var taskCounts = await completedRuns + .SelectMany(m => m.Tasks) + .GroupBy(t => t.Status) + .Select(group => new { Status = group.Key, Count = group.Count() }) + .ToListAsync(); + + int successfulMissions = runData.Count(m => m.Status == MissionStatus.Successful); + int partiallySuccessfulMissions = runData.Count(m => + m.Status == MissionStatus.PartiallySuccessful + ); + var missions = new MissionStatisticsResponse + { + Total = runData.Count, + Successful = successfulMissions, + PartiallySuccessful = partiallySuccessfulMissions, + Failed = runData.Count(m => m.Status == MissionStatus.Failed), + Aborted = runData.Count(m => m.Status == MissionStatus.Aborted), + Cancelled = runData.Count(m => m.Status == MissionStatus.Cancelled), + SuccessRate = CalculateSuccessRate( + successfulMissions, + partiallySuccessfulMissions, + runData.Count + ), + }; + + int TaskCount(TaskStatus status) => + taskCounts.FirstOrDefault(t => t.Status == status)?.Count ?? 0; + + int totalTasks = taskCounts.Sum(t => t.Count); + int successfulTasks = TaskCount(TaskStatus.Successful); + int partiallySuccessfulTasks = TaskCount(TaskStatus.PartiallySuccessful); + var tasks = new TaskStatisticsResponse + { + Total = totalTasks, + Successful = successfulTasks, + PartiallySuccessful = partiallySuccessfulTasks, + SuccessRate = CalculateSuccessRate( + successfulTasks, + partiallySuccessfulTasks, + totalTasks + ), + }; + + var missionsPerWeek = new List(); + int numberOfWeeks = (int)((toTime - fromTime).TotalDays / 7); + for (int week = numberOfWeeks - 1; week >= 0; week--) + { + var weekStart = toTime.AddDays(-7 * (week + 1)); + var weekEnd = toTime.AddDays(-7 * week); + missionsPerWeek.Add( + new WeeklyMissionCountResponse + { + WeekStart = weekStart, + WeekEnd = weekEnd, + Count = runData.Count(m => + m.CreationTime >= weekStart && m.CreationTime < weekEnd + ), + } + ); + } + + return new RobotStatisticsResponse + { + RobotId = robotId, + FromTime = fromTime, + ToTime = toTime, + Missions = missions, + Tasks = tasks, + MissionsPerWeek = missionsPerWeek, + }; + } + + private static double CalculateSuccessRate( + int successful, + int partiallySuccessful, + int total + ) + { + return total == 0 ? 0 : (double)(successful + partiallySuccessful) / total; + } + } +} diff --git a/frontend/src/api/BackendApi.tsx b/frontend/src/api/BackendApi.tsx index 10f8aa6d4..de7342d05 100644 --- a/frontend/src/api/BackendApi.tsx +++ b/frontend/src/api/BackendApi.tsx @@ -12,6 +12,7 @@ import { MissionDefinitionUpdateForm } from 'models/MissionDefinitionUpdateForm' import { filterRobots } from 'utils/filtersAndSorts' import { PointillaMapInfo } from 'models/PointillaMapInfo' import { Installation } from 'models/Installation' +import { RobotStatistics } from 'models/RobotStatistics' export class BackendApi { constructor(private readonly api: BackendAPICaller) {} @@ -38,6 +39,16 @@ export class BackendApi { return result.content } + async getRobotStatistics( + robotId: string, + minCreationTime: number, + maxCreationTime: number + ): Promise { + const path: string = `statistics/robots/${robotId}/missions?minCreationTime=${minCreationTime}&maxCreationTime=${maxCreationTime}` + const result = await this.api.GET(path).catch(handleError('GET', path)) + return result.content + } + async getMissionRuns(parameters: MissionRunQueryParameters): Promise> { let path: string = 'missions/runs?' diff --git a/frontend/src/hooks/useRobotStatistics.tsx b/frontend/src/hooks/useRobotStatistics.tsx new file mode 100644 index 000000000..c37c330db --- /dev/null +++ b/frontend/src/hooks/useRobotStatistics.tsx @@ -0,0 +1,29 @@ +import { useQuery } from '@tanstack/react-query' +import { useBackendApi } from 'api/UseBackendApi' +import { RobotStatistics } from 'models/RobotStatistics' + +const SECONDS_PER_HOUR = 60 * 60 +const SECONDS_PER_DAY = 24 * SECONDS_PER_HOUR +const STATISTICS_WINDOW_DAYS = 30 + +// Bucket "now" to the top of the current hour so the query window - and thus the +// query key - stays stable across renders instead of changing every render tick. +const currentWindow = () => { + const nowSeconds = Math.floor(Date.now() / 1000) + const maxCreationTime = nowSeconds - (nowSeconds % SECONDS_PER_HOUR) + const minCreationTime = maxCreationTime - STATISTICS_WINDOW_DAYS * SECONDS_PER_DAY + return { minCreationTime, maxCreationTime } +} + +export const useRobotStatistics = (robotId: string, enabled: boolean = true) => { + const backendApi = useBackendApi() + const { minCreationTime, maxCreationTime } = currentWindow() + + return useQuery({ + queryKey: ['fetchRobotStatistics', robotId, minCreationTime, maxCreationTime], + queryFn: async () => backendApi.getRobotStatistics(robotId, minCreationTime, maxCreationTime), + retry: 1, + staleTime: 10 * 60 * 1000, // If data is received, stale time is 10 min before making new API call + enabled: enabled && !!robotId, + }) +} diff --git a/frontend/src/language/en.json b/frontend/src/language/en.json index 028e9e164..34f41827b 100644 --- a/frontend/src/language/en.json +++ b/frontend/src/language/en.json @@ -331,6 +331,26 @@ "Temperature [°C]": "Temperature [°C]", "No analysis available": "No analysis available", "Latest analysis result": "Latest analysis result", + "Performance": "Performance", + "Last 30 days": "Last 30 days", + "Loading statistics": "Loading statistics", + "Could not load statistics": "Could not load statistics", + "No missions in the last 30 days": "No missions in the last 30 days", + "Wk": "Wk", + "Mission success": "Mission success", + "success rate": "success rate", + "missions successful / run": "missions successful / run", + "successful": "successful", + "failed / aborted": "failed / aborted", + "Task completion": "Task completion", + "completed": "completed", + "tasks successful / total": "tasks successful / total", + "incomplete": "incomplete", + "Missions per week": "Missions per week", + "avg": "avg", + "wk": "wk", + "document": "document", + "documents": "documents", "shortMonday": "M", "shortTuesday": "T", "shortWednesday": "W", diff --git a/frontend/src/language/no.json b/frontend/src/language/no.json index 8527dc09f..720d1760a 100644 --- a/frontend/src/language/no.json +++ b/frontend/src/language/no.json @@ -332,6 +332,26 @@ "Temperature [°C]": "Temperatur [°C]", "No analysis available": "Ingen analyse ble funnet", "Latest analysis result": "Siste analyseresultat", + "Performance": "Ytelse", + "Last 30 days": "Siste 30 dager", + "Loading statistics": "Laster statistikk", + "Could not load statistics": "Kunne ikke laste statistikk", + "No missions in the last 30 days": "Ingen oppdrag de siste 30 dagene", + "Wk": "Uke", + "Mission success": "Oppdragssuksess", + "success rate": "suksessrate", + "missions successful / run": "oppdrag vellykket / kjørt", + "successful": "vellykket", + "failed / aborted": "feilet / avbrutt", + "Task completion": "Oppgavefullføring", + "completed": "fullført", + "tasks successful / total": "oppgaver vellykket / totalt", + "incomplete": "ufullstendig", + "Missions per week": "Oppdrag per uke", + "avg": "gj.sn", + "wk": "uke", + "document": "dokument", + "documents": "dokumenter", "shortMonday": "M", "shortTuesday": "T", "shortWednesday": "O", diff --git a/frontend/src/models/RobotStatistics.ts b/frontend/src/models/RobotStatistics.ts new file mode 100644 index 000000000..2c8c5ede3 --- /dev/null +++ b/frontend/src/models/RobotStatistics.ts @@ -0,0 +1,31 @@ +interface MissionStatistics { + total: number + successful: number + partiallySuccessful: number + failed: number + aborted: number + cancelled: number + successRate: number +} + +interface TaskStatistics { + total: number + successful: number + partiallySuccessful: number + successRate: number +} + +interface WeeklyMissionCount { + weekStart: string + weekEnd: string + count: number +} + +export interface RobotStatistics { + robotId: string + fromTime: string + toTime: string + missions: MissionStatistics + tasks: TaskStatistics + missionsPerWeek: WeeklyMissionCount[] +} diff --git a/frontend/src/pages/RobotPage/Documentation.tsx b/frontend/src/pages/RobotPage/Documentation.tsx index 47348a84c..612d692c9 100644 --- a/frontend/src/pages/RobotPage/Documentation.tsx +++ b/frontend/src/pages/RobotPage/Documentation.tsx @@ -1,34 +1,94 @@ -import { Typography, Icon } from '@equinor/eds-core-react' +import { Card, Icon, Typography } from '@equinor/eds-core-react' import { Icons } from 'utils/icons' import { tokens } from '@equinor/eds-tokens' import { useLanguageContext } from 'components/Contexts/LanguageContext' import styled from 'styled-components' import { DocumentInfo } from 'models/DocumentInfo' +import { cardShadow } from 'components/Styles/StyledComponents' +import { phone_width } from 'utils/constants' -const DocumentStyle = styled.div` +const StripCard = styled(Card)` display: flex; + flex-direction: row; + align-items: center; gap: 1rem; + padding: 1rem 1.5rem; + box-shadow: ${cardShadow}; + box-sizing: border-box; + @media (max-width: ${phone_width}) { + padding: 1rem; + } +` +const IconCircle = styled.div` + display: flex; align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: 50%; + flex-shrink: 0; + background: ${tokens.colors.ui.background__light.hex}; +` +const TextBlock = styled.div` + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +` +const Subtitle = styled.div` + display: flex; + flex-wrap: wrap; + gap: 6px; + a { + color: ${tokens.colors.interactive.primary__resting.hex}; + text-decoration: none; + } + a:hover { + text-decoration: underline; + } +` +const Spacer = styled.div` + flex: 1 1 auto; +` +const Count = styled(Typography)` + color: ${tokens.colors.text.static_icons__tertiary.hex}; + white-space: nowrap; + @media (max-width: ${phone_width}) { + display: none; + } ` export const DocumentationSection = ({ documentation }: { documentation: DocumentInfo[] }) => { const { TranslateText } = useLanguageContext() + const documentLabel = documentation.length === 1 ? TranslateText('document') : TranslateText('documents') return ( - <> - {TranslateText('Documentation')} - {documentation.map((documentInfo, index) => ( - - - - {documentInfo.name} - - - ))} - + + + + + + {TranslateText('Documentation')} + + {documentation.map((documentInfo, index) => ( + + + {documentInfo.name} + + {index < documentation.length - 1 ? ',' : ''} + + ))} + + + + {`${documentation.length} ${documentLabel}`} + + ) } diff --git a/frontend/src/pages/RobotPage/RobotPage.tsx b/frontend/src/pages/RobotPage/RobotPage.tsx index 7cbb992ec..3be663472 100644 --- a/frontend/src/pages/RobotPage/RobotPage.tsx +++ b/frontend/src/pages/RobotPage/RobotPage.tsx @@ -1,14 +1,17 @@ -import { Button, Icon, Typography } from '@equinor/eds-core-react' +import { Button, Card as EdsCard, Icon, Typography } from '@equinor/eds-core-react' import styled from 'styled-components' +import { Link } from 'react-router-dom' import { Header } from 'components/Header/Header' +import { NavBar } from 'components/Header/NavBar' import { RobotImage } from 'components/Displays/RobotDisplays/RobotImage' import { PressureStatusDisplay } from 'components/Displays/RobotDisplays/PressureStatusDisplay' import { BatteryStatusDisplay } from 'components/Displays/RobotDisplays/BatteryStatusDisplay' import { RobotStatusChip } from 'components/Displays/RobotDisplays/RobotStatusIcon' -import { RobotStatus, RobotWithoutTelemetry } from 'models/Robot' +import { getRobotTypeString, RobotStatus, RobotWithoutTelemetry } from 'models/Robot' import { useLanguageContext } from 'components/Contexts/LanguageContext' -import { VideoStreamSection, FieldLabel } from 'components/Styles/StyledComponents' +import { VideoStreamSection, FieldLabel, cardShadow } from 'components/Styles/StyledComponents' import { DocumentationSection } from './Documentation' +import { RobotStatisticsSection } from './RobotStatistics/RobotStatisticsSection' import { useMediaStreamContext } from 'components/Contexts/MediaStreamContext' import { useContext, useEffect, useState } from 'react' import { VideoStreamWindow } from '../MissionPage/VideoStream/VideoStreamWindow' @@ -28,48 +31,104 @@ import { useAlertContext } from 'components/Contexts/AlertContext' const StyledRobotPage = styled.div` display: flex; flex-direction: column; - background-color: ${tokens.colors.ui.background__default.hex}; - min-height: 100vh; + gap: 2rem; + padding: 2rem 3rem; + box-sizing: border-box; + background-color: ${tokens.colors.ui.background__light.hex}; + min-height: calc(100vh - 65px); + @media (max-width: ${phone_width}) { + padding: 1.25rem; + gap: 1.5rem; + } ` -const HeroSection = styled.div` +const Breadcrumb = styled.div` display: flex; align-items: center; - gap: 3rem; - padding: 2rem 4rem; - background-color: ${tokens.colors.ui.background__light.hex}; + gap: 6px; + font-family: Equinor, sans-serif; + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: ${tokens.colors.text.static_icons__tertiary.hex}; +` + +const BreadcrumbLink = styled(Link)` + color: ${tokens.colors.text.static_icons__tertiary.hex}; + text-decoration: none; + &:hover { + color: ${tokens.colors.interactive.primary__resting.hex}; + } +` + +const BreadcrumbCurrent = styled.span` + color: ${tokens.colors.text.static_icons__default.hex}; +` + +const Card = styled(EdsCard)` + box-shadow: ${cardShadow}; box-sizing: border-box; +` + +const HeroCard = styled(Card)` + display: flex; + flex-direction: row; + align-items: center; + gap: 2.5rem; + padding: 1.75rem 2rem; + border-left: 4px solid ${tokens.colors.interactive.primary__resting.hex}; @media (max-width: ${phone_width}) { flex-direction: column; - padding: 1.5rem; - gap: 16px; align-items: flex-start; + gap: 1.25rem; + padding: 1.25rem; } ` -const HeroLeft = styled.div` +const HeroInfo = styled.div` display: flex; flex-direction: column; - gap: 16px; + gap: 12px; ` -const MetricsRow = styled.div` +const RobotTypeText = styled(Typography)` + font-size: 0.9rem; + color: ${tokens.colors.text.static_icons__tertiary.hex}; +` + +const HeroSpacer = styled.div` + flex: 1 1 auto; +` + +const HeroActions = styled.div` + display: flex; + flex-wrap: wrap; + gap: 12px; + @media (max-width: ${phone_width}) { + width: 100%; + } +` + +const MetricsCard = styled(Card)` display: flex; + flex-direction: row; flex-wrap: wrap; - padding: 2rem 4rem; + padding: 1.5rem 2rem; @media (max-width: ${phone_width}) { flex-direction: column; - gap: 1rem; - padding: 1rem 1.5rem; + gap: 1.25rem; + padding: 1.25rem; } ` -const MetricCard = styled.div` +const MetricColumn = styled.div` display: flex; flex-direction: column; gap: 10px; - min-width: 140px; - padding: 0 2rem; + flex: 1 1 0; + min-width: 150px; + padding: 0 1.75rem; border-left: 1px solid ${tokens.colors.ui.background__medium.hex}; &:first-child { padding-left: 0; @@ -81,17 +140,6 @@ const MetricCard = styled.div` } ` -const ActionsRow = styled.div` - display: flex; - flex-wrap: wrap; - gap: 12px; - padding: 0 4rem 2rem 4rem; - @media (max-width: ${phone_width}) { - padding: 0 1.5rem 1.5rem 1.5rem; - flex-direction: column; - } -` - interface RobotPageProps { robot: RobotWithoutTelemetry } @@ -159,58 +207,70 @@ export const RobotPage = ({ robot }: RobotPageProps) => { return ( <>
+ {robot && ( <> - - - + + + {TranslateText('Mission Control')} + + + {robot.name} + + + + + {robot.name} - - + {robot.type && {getRobotTypeString(robot.type)}} + + + + {stopButton} + {robot.status != RobotStatus.InterventionNeeded && } + {robot.status == RobotStatus.InterventionNeeded && ( + + )} + + + {robot.status !== RobotStatus.Offline && ( - - + + {TranslateText('Battery')} - + {robotPressureLevel !== undefined && ( - + {TranslateText('Pressure')} - + )} {robot.type && ( - + {TranslateText('Robot Model')} {robot.type} - + )} {currentInspectionArea && ( - + {TranslateText('Current Inspection Area')} {currentInspectionArea.inspectionAreaName} - + )} - + )} - - {stopButton} - {robot.status != RobotStatus.InterventionNeeded && } - {robot.status == RobotStatus.InterventionNeeded && ( - - )} - - + {skipMissionDialog} {robot.documentation && robot.documentation.length > 0 && ( diff --git a/frontend/src/pages/RobotPage/RobotStatistics/DonutChart.tsx b/frontend/src/pages/RobotPage/RobotStatistics/DonutChart.tsx new file mode 100644 index 000000000..3db424bdc --- /dev/null +++ b/frontend/src/pages/RobotPage/RobotStatistics/DonutChart.tsx @@ -0,0 +1,77 @@ +import { tokens } from '@equinor/eds-tokens' +import styled from 'styled-components' + +const RADIUS = 52 +const STROKE = 16 +const SIZE = 140 +const CENTER = SIZE / 2 +const CIRCUMFERENCE = 2 * Math.PI * RADIUS + +const StyledSvg = styled.svg` + display: block; + flex-shrink: 0; + font-family: Equinor, sans-serif; +` + +interface DonutChartProps { + fraction: number + color: string + caption: string +} + +export const DonutChart = ({ fraction, color, caption }: DonutChartProps) => { + const clamped = Math.min(Math.max(fraction, 0), 1) + const percentage = Math.round(clamped * 100) + const dashArray = `${clamped * CIRCUMFERENCE} ${CIRCUMFERENCE}` + + return ( + + + + + {percentage}% + + + {caption} + + + ) +} diff --git a/frontend/src/pages/RobotPage/RobotStatistics/RobotStatisticsSection.tsx b/frontend/src/pages/RobotPage/RobotStatistics/RobotStatisticsSection.tsx new file mode 100644 index 000000000..fe488a7e1 --- /dev/null +++ b/frontend/src/pages/RobotPage/RobotStatistics/RobotStatisticsSection.tsx @@ -0,0 +1,238 @@ +import { tokens } from '@equinor/eds-tokens' +import styled from 'styled-components' +import { Card, Typography } from '@equinor/eds-core-react' +import { useLanguageContext } from 'components/Contexts/LanguageContext' +import { FieldLabel, subtleCardShadow } from 'components/Styles/StyledComponents' +import { phone_width } from 'utils/constants' +import { useRobotStatistics } from 'hooks/useRobotStatistics' +import { DonutChart } from './DonutChart' +import { WeeklyBarChart } from './WeeklyBarChart' + +const Section = styled.div` + display: flex; + flex-direction: column; + gap: 1rem; +` +const SectionHeading = styled.div` + display: flex; + align-items: baseline; + gap: 8px; +` +const HeadingTitle = styled(Typography)` + font-family: Equinor, sans-serif; + font-size: 0.82rem; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; + color: ${tokens.colors.text.static_icons__default.hex}; +` +const HeadingSubtitle = styled(Typography)` + font-family: Equinor, sans-serif; + font-size: 0.82rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: ${tokens.colors.text.static_icons__tertiary.hex}; +` +const CardsRow = styled.div` + display: flex; + flex-wrap: wrap; + gap: 24px; +` +const StatCard = styled(Card)` + display: flex; + flex-direction: column; + gap: 16px; + flex: 1 1 320px; + min-width: 280px; + padding: 20px 24px; + box-sizing: border-box; + box-shadow: ${subtleCardShadow}; + @media (max-width: ${phone_width}) { + padding: 16px; + } +` +const CardHeaderRow = styled.div` + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 8px; +` +const MutedCaption = styled(Typography)` + color: ${tokens.colors.text.static_icons__tertiary.hex}; +` +const DonutRow = styled.div` + display: flex; + align-items: center; + gap: 24px; +` +const DonutInfo = styled.div` + display: flex; + flex-direction: column; + gap: 8px; +` +const BigNumber = styled(Typography)` + font-family: Equinor, sans-serif; + font-size: 2rem; + font-weight: 700; + line-height: 1; + color: ${tokens.colors.text.static_icons__default.hex}; +` +const Denominator = styled(Typography)` + font-size: 1.25rem; + font-weight: 400; + color: ${tokens.colors.text.static_icons__tertiary.hex}; +` +const Legend = styled.div` + display: flex; + flex-direction: column; + gap: 6px; +` +const LegendRow = styled.div` + display: flex; + align-items: center; + gap: 8px; +` +const LegendSwatch = styled.span<{ $color: string }>` + width: 12px; + height: 12px; + border-radius: 2px; + flex-shrink: 0; + background: ${({ $color }) => $color}; +` +const StateMessage = styled.div` + display: flex; + padding: 2rem 0; + color: ${tokens.colors.text.static_icons__tertiary.hex}; +` + +interface DonutStatCardProps { + label: string + fraction: number + color: string + caption: string + successCount: number + totalCount: number + ratioCaption: string + successLegend: string + restCount: number + restLegend: string +} + +const DonutStatCard = ({ + label, + fraction, + color, + caption, + successCount, + totalCount, + ratioCaption, + successLegend, + restCount, + restLegend, +}: DonutStatCardProps) => ( + + {label} + + + + + {successCount} + / {totalCount} + + {ratioCaption} + + + + {`${successCount} ${successLegend}`} + + + + {`${restCount} ${restLegend}`} + + + + + +) + +export const RobotStatisticsSection = ({ robotId }: { robotId: string }) => { + const { TranslateText } = useLanguageContext() + const { data: statistics, isPending, isError } = useRobotStatistics(robotId) + + const heading = ( + + {TranslateText('Performance')} + {`· ${TranslateText('Last 30 days')}`} + + ) + + if (isPending || isError || statistics.missions.total === 0) { + const message = isPending + ? TranslateText('Loading statistics') + '...' + : isError + ? TranslateText('Could not load statistics') + : TranslateText('No missions in the last 30 days') + return ( +
+ {heading} + + {message} + +
+ ) + } + + const { missions, tasks, missionsPerWeek } = statistics + const missionsSuccessful = missions.successful + missions.partiallySuccessful + const missionsUnsuccessful = Math.max(missions.total - missionsSuccessful, 0) + const tasksSuccessful = tasks.successful + tasks.partiallySuccessful + const tasksIncomplete = Math.max(tasks.total - tasksSuccessful, 0) + const weeklyData = missionsPerWeek.map((week, index) => ({ + label: `${TranslateText('Wk')} ${index + 1}`, + value: week.count, + })) + const weeklyAverage = weeklyData.length + ? weeklyData.reduce((sum, week) => sum + week.value, 0) / weeklyData.length + : 0 + + return ( +
+ {heading} + + + + + + {TranslateText('Missions per week')} + + {`${TranslateText('avg')} ${weeklyAverage.toFixed(1)} / ${TranslateText('wk')}`} + + + + + +
+ ) +} diff --git a/frontend/src/pages/RobotPage/RobotStatistics/WeeklyBarChart.tsx b/frontend/src/pages/RobotPage/RobotStatistics/WeeklyBarChart.tsx new file mode 100644 index 000000000..db8a7755f --- /dev/null +++ b/frontend/src/pages/RobotPage/RobotStatistics/WeeklyBarChart.tsx @@ -0,0 +1,94 @@ +import { tokens } from '@equinor/eds-tokens' +import styled from 'styled-components' + +const WIDTH = 360 +const HEIGHT = 190 +const TOP_PAD = 28 +const BOTTOM_PAD = 28 +const BASELINE = HEIGHT - BOTTOM_PAD +const BAR_MAX_HEIGHT = BASELINE - TOP_PAD +const MAX_BAR_WIDTH = 56 + +const StyledSvg = styled.svg` + display: block; + width: 100%; + height: auto; + font-family: Equinor, sans-serif; +` + +interface WeeklyBar { + label: string + value: number +} + +interface WeeklyBarChartProps { + data: WeeklyBar[] +} + +export const WeeklyBarChart = ({ data }: WeeklyBarChartProps) => { + if (data.length === 0) return null + + const maxValue = Math.max(...data.map((bar) => bar.value), 1) + const average = data.reduce((sum, bar) => sum + bar.value, 0) / data.length + const averageY = BASELINE - (average / maxValue) * BAR_MAX_HEIGHT + const slotWidth = WIDTH / data.length + const barWidth = Math.min(slotWidth * 0.5, MAX_BAR_WIDTH) + + return ( + + + + {data.map((bar, index) => { + const centerX = index * slotWidth + slotWidth / 2 + const barHeight = (bar.value / maxValue) * BAR_MAX_HEIGHT + const barY = BASELINE - barHeight + return ( + + + + {bar.value} + + + {bar.label} + + + ) + })} + + ) +}