Merge branch 'robot-report' into dev

This commit is contained in:
Mikhail Trubnikov
2026-06-15 11:02:00 +10:00
10 changed files with 447 additions and 22 deletions

View File

@@ -50,6 +50,8 @@
public const string GetNextRun = Base + "/tests/next-run";
public const string CreateCache = Base + "/tests/cache/";
public const string TestHandler = Base + "/tests/test/";
}
public static class Template

View File

@@ -23,5 +23,10 @@
/// Ошибок
/// </summary>
public int Errors { get; set; }
/// <summary>
/// Среднее кол-во роботов работающих в течении часа
/// </summary>
public double AvgRobots { get; set; }
}
}

View File

@@ -8,7 +8,9 @@ using PARR.API.Contracts.V1.Responses.Base;
using PARR.API.Contracts.V1.Responses.Statistics;
using PARR.API.Controllers.V1.Base;
using PARR.Core.Repositories.Interfaces;
using PARR.Core.Services.RobotSnapshotServices;
using PARR.Domain.Common.Roles;
using PARR.Domain.DTOs.RobotSnapshotDTO;
using PARR.Domain.Enums;
namespace PARR.API.Controllers.V1.Statistics
@@ -24,13 +26,15 @@ namespace PARR.API.Controllers.V1.Statistics
private readonly ITaskStatusRepository taskStatusService;
private readonly IRobotHistoryRepository robotHistoryService;
private readonly IMapper mapper;
private readonly IRobotSnapshotService _robotSnapshotService;
public StatRobotTaskController(
IRobotRepository robotService,
IRobotConfigurationRepository robotConfigurationService,
ITaskStatusRepository taskStatusService,
IRobotHistoryRepository robotHistoryService,
IMapper mapper
IMapper mapper,
IRobotSnapshotService robotSnapshotService
)
{
this.robotService = robotService;
@@ -38,6 +42,7 @@ namespace PARR.API.Controllers.V1.Statistics
this.taskStatusService = taskStatusService;
this.robotHistoryService = robotHistoryService;
this.mapper = mapper;
_robotSnapshotService = robotSnapshotService;
}
/// <summary>
@@ -87,11 +92,11 @@ namespace PARR.API.Controllers.V1.Statistics
[HttpGet(ApiRoutes.StatRobotTask.GetPeriodStatistics)]
public async Task<IActionResult> GetPeriodStatistics([FromRoute] RobotsEnum robot)
{
var offset = TimeSpan.FromHours(0);
int minusHour = 24;
var queryDate = DateTimeOffset.UtcNow.AddHours(-minusHour);
queryDate = new DateTimeOffset(queryDate.Year, queryDate.Month, queryDate.Day, queryDate.Hour, 0, 0, new TimeSpan(0));
//queryDate = queryDate.Date + new TimeSpan(queryDate.Hour, 0, 0);
//queryDate=queryDate.ToOffset(TimeSpan.Zero);
queryDate = new DateTimeOffset(queryDate.Year, queryDate.Month, queryDate.Day, queryDate.Hour, 0, 0, offset);
var query = robotHistoryService.Get()
@@ -110,16 +115,24 @@ namespace PARR.API.Controllers.V1.Statistics
var statistics = await query.ToListAsync();
//if (!statistics.Any())
// return NoContent();
var periodList = GetPeriodList(queryDate, DateTimeOffset.UtcNow);
if (!periodList.Any())
return NoContent();
// Статистика по роботам (кол-во роботов)
var robotStats = await _robotSnapshotService.GetHourlyAnalyticsByRobotTypeAsync(new RobotAnalyticsRobotTypeQuery
{
DateStart = queryDate,
DateEnd = DateTimeOffset.UtcNow,
Offset = offset,
RobotType = robot
});
periodList.ForEach(item =>
{
var robotCount = robotStats.FirstOrDefault(t => t.Hour.Date == item.Date.Date && t.Hour.Hour == item.Date.Hour);
var statItem = statistics.FirstOrDefault(t => t.Date.Date == item.Date.Date && t.Date.Hour == item.Date.Hour);
if (statItem != null)
{
@@ -127,6 +140,7 @@ namespace PARR.API.Controllers.V1.Statistics
item.Creating = statItem.Creating;
item.Updating = statItem.Updating;
item.Ok = statItem.Ok;
item.AvgRobots = robotCount?.AvgRobots ?? 0;
}
});
@@ -150,6 +164,7 @@ namespace PARR.API.Controllers.V1.Statistics
Errors = 0,
Ok = 0,
Updating = 0,
AvgRobots = 0,
Date = new DateTimeOffset(periodDate.Date.Year, periodDate.Date.Month, periodDate.Date.Day, periodDate.Hour, 0, 0, new TimeSpan(0)),
});

View File

@@ -10,9 +10,11 @@ using PARR.Core.Common.Interfaces;
using PARR.Core.Repositories.Interfaces;
using PARR.Core.Repositories.Interfaces.Unit;
using PARR.Core.Services.NextRunServices;
using PARR.Core.Services.RobotSnapshotServices;
using PARR.Core.Services.UnitService.Interfaces;
using PARR.Core.Services.Workload.Implementations;
using PARR.Domain.Cache;
using PARR.Domain.DTOs.RobotSnapshotDTO;
namespace PARR.API.Controllers.V1
{
@@ -27,6 +29,7 @@ namespace PARR.API.Controllers.V1
private readonly ILogger<TestController> logger;
private readonly IUnitService unitService;
private readonly IUnitRepository unitRepository;
private readonly IRobotSnapshotService _robotSnapshotService;
public TestController(
IClientService clientService,
@@ -37,7 +40,8 @@ namespace PARR.API.Controllers.V1
WorkloadCacheService workloadCacheService,
ILogger<TestController> logger,
IUnitService unitService,
IUnitRepository unitRepository
IUnitRepository unitRepository,
IRobotSnapshotService robotSnapshotService
)
{
this.clientService = clientService;
@@ -48,6 +52,7 @@ namespace PARR.API.Controllers.V1
this.logger = logger;
this.unitService = unitService;
this.unitRepository = unitRepository;
_robotSnapshotService = robotSnapshotService;
}
@@ -161,6 +166,32 @@ namespace PARR.API.Controllers.V1
}
/// <summary>
/// Тестовый метод
/// </summary>
/// <returns></returns>
[HttpGet(ApiRoutes.Test.TestHandler)]
public async Task<IActionResult> Test()
{
//var data = await _robotSnapshotService.GetHourlyAnalyticsAsync(new RobotAnalyticsQuery
//{
// DateStart = DateTimeOffset.UtcNow.AddHours(-1),
// DateEnd = DateTimeOffset.UtcNow,
// Offset = TimeSpan.FromMinutes(600)
//});
//var data = await _robotSnapshotService.GetHourlyAnalyticsByRobotTypeAsync(new RobotAnalyticsRobotTypeQuery
//{
// DateStart = DateTimeOffset.UtcNow.AddHours(-12),
// DateEnd = DateTimeOffset.UtcNow,
// Offset = TimeSpan.FromMinutes(600),
// RobotType = PARR.Domain.Enums.RobotsEnum.TemplateOrder
//});
//return Ok(data);
return Ok();
}

View File

@@ -4,8 +4,34 @@ namespace PARR.Core.Services.RobotSnapshotServices
{
public interface IRobotSnapshotService
{
/// <summary>
/// Получить статистику
/// </summary>
/// <param name="queryDto"></param>
/// <returns></returns>
Task<List<RobotSnapshotItemDto>> GetAsync(RobotSnapshotQuery queryDto);
/// <summary>
/// Записать статистику
/// </summary>
/// <param name="robotSnapshot"></param>
/// <returns></returns>
Task<RobotSnapshotItemDto> CreateAsync(CreateRobotSnapshot robotSnapshot);
/// <summary>
/// Получить аналитику за период с группировкой по ip серверов. Разбивка по часам.
/// Максимальная одновременная работа роботов. Среднее кол-во роботов в час. Максимально возможное кол-во роботов.
/// </summary>
/// <param name="queryDto"></param>
/// <returns></returns>
Task<List<ServerHourlyAnalyticsDto>> GetHourlyAnalyticsAsync(RobotAnalyticsQuery queryDto);
/// <summary>
/// Получить аналитику за период по типу робота. Разбивка по часам.
/// Среднее кол-во роботов в час.
/// </summary>
/// <param name="queryDto"></param>
/// <returns></returns>
Task<List<HourlyRobotTypeAnalyticsDto>> GetHourlyAnalyticsByRobotTypeAsync(RobotAnalyticsRobotTypeQuery queryDto);
}
}

View File

@@ -11,9 +11,9 @@ namespace PARR.Core.Services.RobotSnapshotServices
{
internal class RobotSnapshotService : IRobotSnapshotService
{
private readonly IRobotSnapshotRepository robotSnapshotRepository;
private readonly IUserRepository userRepository;
private readonly IMapper mapper;
private readonly IRobotSnapshotRepository _robotSnapshotRepository;
private readonly IUserRepository _userRepository;
private readonly IMapper _mapper;
public RobotSnapshotService(
IRobotSnapshotRepository robotSnapshotRepository,
@@ -21,9 +21,9 @@ namespace PARR.Core.Services.RobotSnapshotServices
IMapper mapper
)
{
this.robotSnapshotRepository = robotSnapshotRepository;
this.userRepository = userRepository;
this.mapper = mapper;
_robotSnapshotRepository = robotSnapshotRepository;
_userRepository = userRepository;
_mapper = mapper;
}
@@ -37,7 +37,7 @@ namespace PARR.Core.Services.RobotSnapshotServices
var dateEnd = DateTimeOffset.UtcNow;
var dateStart = dateEnd.AddMinutes(-minutes);
var query = robotSnapshotRepository.Get().AsNoTracking()
var query = _robotSnapshotRepository.Get().AsNoTracking()
.Where(t => dateStart <= t.DateCreated && t.DateCreated <= dateEnd);
if (!string.IsNullOrEmpty(queryDto.Ip))
@@ -47,7 +47,7 @@ namespace PARR.Core.Services.RobotSnapshotServices
var robotIps = groupingSnapshots.Select(t => t.Key).ToHashSet();
var robotList = await userRepository.Get()
var robotList = await _userRepository.Get()
.AsNoTracking()
.Where(t => robotIps.Contains(t.Ip))
.ToDictionaryAsync(t => t.Ip);
@@ -59,12 +59,244 @@ namespace PARR.Core.Services.RobotSnapshotServices
robotList.TryGetValue(item.Key, out var robot);
var userDto = new UserBaseDto { Ip = item.Key, Description = robot?.Description ?? string.Empty, Name = robot?.Name ?? string.Empty };
var snapshots = mapper.Map<List<RobotSnapshotDto>>(item).OrderBy(t => t.DateCreated).ToList();
var snapshots = _mapper.Map<List<RobotSnapshotDto>>(item).OrderBy(t => t.DateCreated).ToList();
result.Add(new RobotSnapshotItemDto { Robot = userDto, Snapshots = snapshots });
}
return result.OrderBy(t => t.Robot.Ip).ThenBy(t => t.Robot.Name).ToList();
return result.OrderBy(t => t.Robot.Name).ThenBy(t => t.Robot.Ip).ToList();
}
public async Task<List<ServerHourlyAnalyticsDto>> GetHourlyAnalyticsAsync(RobotAnalyticsQuery queryDto)
{
var snapshotsQuery = _robotSnapshotRepository.Get()
.AsNoTracking()
.Where(t => queryDto.DateStart <= t.DateCreated && t.DateCreated <= queryDto.DateEnd);
// Фильтруем по конкретному IP
if (!string.IsNullOrEmpty(queryDto.Ip))
snapshotsQuery = snapshotsQuery.Where(t => t.Ip == queryDto.Ip);
// Группируем на стороне PostgreSQL с учетом часового пояса пользователя.
// Метод .Add(offset) сдвигает UTC-дату в Postgres на интервал времени пользователя,
// благодаря чему минуты объединяются в правильные локальные "часовые корзины".
var rawGroupedData = await snapshotsQuery
.GroupBy(t => new
{
t.Ip,
// Явно сдвигаем дату и достаем только нужные компоненты
Year = (t.DateCreated + queryDto.Offset).DateTime.Year,
Month = (t.DateCreated + queryDto.Offset).DateTime.Month,
Day = (t.DateCreated + queryDto.Offset).DateTime.Day,
Hour = (t.DateCreated + queryDto.Offset).DateTime.Hour
})
.Select(g => new
{
g.Key.Ip,
// Вытаскиваем компоненты даты из уже смещенного локального времени
LocalYear = g.Key.Year,
LocalMonth = g.Key.Month,
LocalDay = g.Key.Day,
LocalHour = g.Key.Hour,
MaxAllowed = g.Max(t => t.MaxRobots),
MaxTemplates = g.Max(t => t.TemplateRobotsCount),
MaxSchedules = g.Max(t => t.ScheduleRobotsCount),
SumTemplates = g.Sum(t => t.TemplateRobotsCount),
SumSchedules = g.Sum(t => t.ScheduleRobotsCount)
})
.ToListAsync();
if (!rawGroupedData.Any())
return new List<ServerHourlyAnalyticsDto>();
// Получаем информацию о серверах
var serverIps = rawGroupedData.Select(g => g.Ip).ToHashSet();
var serverDictionary = await _userRepository.Get()
.AsNoTracking()
.Where(t => serverIps.Contains(t.Ip))
.ToDictionaryAsync(t => t.Ip);
// Группируем полученные агрегаты по серверам уже в памяти
var dataByServers = rawGroupedData.GroupBy(g => g.Ip);
var result = new List<ServerHourlyAnalyticsDto>();
// Принудительно переводим UTC-границы запроса в локальное время пользователя для корректной генерации "дыр"
var localStart = queryDto.DateStart.ToOffset(queryDto.Offset).DateTime;
var localEnd = queryDto.DateEnd.ToOffset(queryDto.Offset).DateTime;
// Округляем начальный час до ровного значения (00 минут, 00 секунд)
var startHour = new DateTime(localStart.Year, localStart.Month, localStart.Day, localStart.Hour, 0, 0);
foreach (var serverGroup in dataByServers)
{
serverDictionary.TryGetValue(serverGroup.Key, out var robotInfo);
var userDto = new UserBaseDto
{
Ip = serverGroup.Key,
Description = robotInfo?.Description ?? string.Empty,
Name = robotInfo?.Name ?? string.Empty
};
// Формируем точки, которые удалось вытащить из базы данных
var hourlyPoints = serverGroup.Select(g => new RobotHourlyPointDto
{
// Возвращаем дату со смещением пользователя, чтобы Angular сразу отображал правильный час
Hour = new DateTimeOffset(g.LocalYear, g.LocalMonth, g.LocalDay, g.LocalHour, 0, 0, TimeSpan.Zero),
MaxAllowedRobots = g.MaxAllowed,
MaxTemplateRobots = g.MaxTemplates,
MaxScheduleRobots = g.MaxSchedules,
// Считаем среднее количество роботов, деля поминутную сумму строго на 60 минут часа
AvgTemplateRobots = Math.Round(g.SumTemplates / 60.0, 1),
AvgScheduleRobots = Math.Round(g.SumSchedules / 60.0, 1)
})
.ToList();
// Алгоритм заполнения пропусков во времени (если сервер был выключен)
var filledPoints = new List<RobotHourlyPointDto>();
var currentHour = startHour;
int lastKnownMax = hourlyPoints.FirstOrDefault()?.MaxAllowedRobots ?? 0;
while (currentHour <= localEnd)
{
// Создаем временную точку со смещением для точного сопоставления
var targetOffsetDateTime = new DateTimeOffset(currentHour, TimeSpan.Zero);
var point = hourlyPoints.FirstOrDefault(p => p.Hour == targetOffsetDateTime);
if (point != null)
{
filledPoints.Add(point);
lastKnownMax = point.MaxAllowedRobots;
}
else
{
// Если за этот час записей в БД нет — значит, сервер был полностью оффлайн.
// Выводим нули, чтобы график в Angular не прерывался, а плавно падал.
filledPoints.Add(new RobotHourlyPointDto
{
Hour = targetOffsetDateTime,
MaxAllowedRobots = lastKnownMax,
MaxTemplateRobots = 0,
AvgTemplateRobots = 0,
MaxScheduleRobots = 0,
AvgScheduleRobots = 0
});
}
// Шагаем строго на +1 час вперед по локальному времени клиента
currentHour = currentHour.AddHours(1);
}
result.Add(new ServerHourlyAnalyticsDto
{
Robot = userDto,
Snapshots = filledPoints.OrderBy(p => p.Hour).ToList()
});
}
return result
.OrderBy(t => t.Robot.Name)
.ThenBy(t => t.Robot.Ip)
.ToList();
}
public async Task<List<HourlyRobotTypeAnalyticsDto>> GetHourlyAnalyticsByRobotTypeAsync(RobotAnalyticsRobotTypeQuery queryDto)
{
var snapshotsQuery = _robotSnapshotRepository.Get()
.AsNoTracking()
.Where(t => queryDto.DateStart <= t.DateCreated && t.DateCreated <= queryDto.DateEnd)
.GroupBy(t => new
{
// Явно сдвигаем дату
Year = (t.DateCreated + queryDto.Offset).DateTime.Year,
Month = (t.DateCreated + queryDto.Offset).DateTime.Month,
Day = (t.DateCreated + queryDto.Offset).DateTime.Day,
Hour = (t.DateCreated + queryDto.Offset).DateTime.Hour
});
IQueryable<RobotSnapshotRobotTypeDbDto> groupedQuery;
switch (queryDto.RobotType)
{
case Domain.Enums.RobotsEnum.TemplateOrder:
groupedQuery = snapshotsQuery.Select(g => new RobotSnapshotRobotTypeDbDto
{
// Вытаскиваем компоненты даты из уже смещенного локального времени
LocalYear = g.Key.Year,
LocalMonth = g.Key.Month,
LocalDay = g.Key.Day,
LocalHour = g.Key.Hour,
// Макс не правильно.
//MaxAllowed = g.Max(t => t.MaxRobots),
Sum = g.Sum(t => t.TemplateRobotsCount)
});
break;
case Domain.Enums.RobotsEnum.ScheduleOrder:
groupedQuery = snapshotsQuery.Select(g => new RobotSnapshotRobotTypeDbDto
{
// Вытаскиваем компоненты даты из уже смещенного локального времени
LocalYear = g.Key.Year,
LocalMonth = g.Key.Month,
LocalDay = g.Key.Day,
LocalHour = g.Key.Hour,
// Макс не правильно.
//MaxAllowed = g.Max(t => t.MaxRobots),
Sum = g.Sum(t => t.ScheduleRobotsCount)
});
break;
default:
throw new ArgumentException("Не обработано значение параметра", nameof(queryDto.RobotType));
}
var data = await groupedQuery.ToListAsync();
if (!data.Any())
return new List<HourlyRobotTypeAnalyticsDto>();
var dbDataDict = data.ToDictionary(
k => new DateTime(k.LocalYear, k.LocalMonth, k.LocalDay, k.LocalHour, 0, 0),
v => new
{
//MaxAllowedRobots = t.MaxAllowed,
AvgRobots = Math.Round(v.Sum / 60.0, 1)
});
// Заполняем модель
var statistics = new List<HourlyRobotTypeAnalyticsDto>();
#region Алгоритм заполнения пропусков во времени (если сервер был выключен)
// Превращаем границы в локальное время для генерации сетки часов
var currentHour = queryDto.DateStart.ToOffset(queryDto.Offset).DateTime;
var localEnd = queryDto.DateEnd.ToOffset(queryDto.Offset).DateTime;
// Срезаем минуты/секунды для старта
currentHour = new DateTime(currentHour.Year, currentHour.Month, currentHour.Day, currentHour.Hour, 0, 0);
while (currentHour <= localEnd)
{
// Ищем значение в словаре. Если нет — ставим 0
dbDataDict.TryGetValue(currentHour, out var statInfo);
// 1. Создаем DateTimeOffset для локального времени с правильным смещением
var localDateTimeOffset = new DateTimeOffset(currentHour, queryDto.Offset);
statistics.Add(new HourlyRobotTypeAnalyticsDto
{
// Возвращаем DateTimeOffset с нулевым смещением (как в вашем коде) или с queryDto.Offset
Hour = localDateTimeOffset.ToUniversalTime(),
AvgRobots = statInfo?.AvgRobots ?? 0
});
currentHour = currentHour.AddHours(1);
}
#endregion
return statistics;
}
@@ -79,19 +311,19 @@ namespace PARR.Core.Services.RobotSnapshotServices
Ip = robotSnapshot.Ip
};
var createdResult = await robotSnapshotRepository.CreateAsync(snapshot);
var commitResult = await robotSnapshotRepository.CommitAsync();
var createdResult = await _robotSnapshotRepository.CreateAsync(snapshot);
var commitResult = await _robotSnapshotRepository.CommitAsync();
if (!createdResult || !commitResult)
throw new DbErrorException("Ошибка сохранения в БД");
var user = await userRepository.Get().AsNoTracking().FirstOrDefaultAsync(t => t.Ip == robotSnapshot.Ip);
var user = await _userRepository.Get().AsNoTracking().FirstOrDefaultAsync(t => t.Ip == robotSnapshot.Ip);
var userDto = new UserBaseDto { Ip = robotSnapshot.Ip, Description = user?.Description ?? string.Empty, Name = user?.Name ?? string.Empty };
var result = new RobotSnapshotItemDto
{
Robot = userDto,
Snapshots = mapper.Map<List<RobotSnapshotDto>>(new List<RobotSnapshot> { snapshot })
Snapshots = _mapper.Map<List<RobotSnapshotDto>>(new List<RobotSnapshot> { snapshot })
};
return result;

View File

@@ -0,0 +1,17 @@
namespace PARR.Domain.DTOs.RobotSnapshotDTO
{
/// <summary>
/// Почасовая статистика по снапшотам роботов
/// </summary>
public record HourlyRobotTypeAnalyticsDto
{
public DateTimeOffset Hour { get; init; }
//public int MaxAllowedRobots { get; init; }
/// <summary>
/// Среднее кол-во роботов работающих в течении часа
/// </summary>
public double AvgRobots { get; init; }
}
}

View File

@@ -0,0 +1,30 @@
using PARR.Domain.Enums;
namespace PARR.Domain.DTOs.RobotSnapshotDTO
{
public record RobotAnalyticsBaseQuery
{
public DateTimeOffset DateStart { get; init; }
public DateTimeOffset DateEnd { get; init; }
/// <summary>
/// Смещение часового пояса
/// </summary>
public TimeSpan Offset { get; init; }
}
public record RobotAnalyticsRobotTypeQuery : RobotAnalyticsBaseQuery
{
/// <summary>
/// Тип робота
/// </summary>
public RobotsEnum RobotType { get; init; }
}
public record RobotAnalyticsQuery : RobotAnalyticsBaseQuery
{
public string? Ip { get; init; }
}
}

View File

@@ -0,0 +1,20 @@
namespace PARR.Domain.DTOs.RobotSnapshotDTO
{
/// <summary>
/// Селект из БД по снапшотам по типам роботов
/// </summary>
public record RobotSnapshotRobotTypeDbDto
{
public int LocalYear { get; init; }
public int LocalMonth { get; init; }
public int LocalDay { get; init; }
public int LocalHour { get; init; }
//public int MaxAllowed { get; init; }
public int Sum { get; init; }
}
}

View File

@@ -0,0 +1,47 @@
using PARR.Domain.DTOs.User;
namespace PARR.Domain.DTOs.RobotSnapshotDTO
{
/// <summary>
/// Аналитика по снапшотам роботов с группировкой по IP робота
/// </summary>
public record ServerHourlyAnalyticsDto
{
public UserBaseDto Robot { get; init; } = null!;
/// <summary>
/// Почасовая статистика для гарфиков
/// </summary>
public List<RobotHourlyPointDto> Snapshots { get; set; } = new();
}
/// <summary>
/// Почасовая статистика по снапшотам роботов
/// </summary>
public record RobotHourlyPointDto
{
public DateTimeOffset Hour { get; set; }
public int MaxAllowedRobots { get; set; }
/// <summary>
/// Максимальное кол-во роботов работающих одновременно по шаблонам
/// </summary>
public int MaxTemplateRobots { get; set; }
/// <summary>
/// Сколько роботов в среднем, работало в течении часа
/// </summary>
public double AvgTemplateRobots { get; set; }
/// <summary>
/// Максимальное кол-во роботов работающих одновременно по расписаниям
/// </summary>
public int MaxScheduleRobots { get; set; }
/// <summary>
/// Сколько роботов в среднем, работало в течении часа
/// </summary>
public double AvgScheduleRobots { get; set; }
}
}