Compare commits
7 Commits
main
...
a74f29b246
| Author | SHA1 | Date | |
|---|---|---|---|
| a74f29b246 | |||
| a8d51df3f1 | |||
| e8f7a64d15 | |||
| c1597a8649 | |||
| f9d3f1ac56 | |||
| 62dbeecd9f | |||
| edd276664c |
@@ -14,7 +14,7 @@
|
|||||||
<PackageReference Include="NSubstitute" Version="5.3.0" />
|
<PackageReference Include="NSubstitute" Version="5.3.0" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
|
||||||
<PackageReference Include="xunit" Version="2.9.3" />
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.0">
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
|
|||||||
@@ -159,6 +159,19 @@ public class UsersController : ControllerBase
|
|||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
public async Task<ActionResult<UserStatsDto>> Stats(int id) => Ok(await _users.GetStatsAsync(id));
|
public async Task<ActionResult<UserStatsDto>> Stats(int id) => Ok(await _users.GetStatsAsync(id));
|
||||||
|
|
||||||
|
/// <summary>Получить статистику для админского дашборда.</summary>
|
||||||
|
/// <remarks>Только Admin.</remarks>
|
||||||
|
/// <response code="200">Агрегированная статистика дашборда.</response>
|
||||||
|
/// <response code="401">Требуется аутентификация.</response>
|
||||||
|
/// <response code="403">Требуется роль Admin.</response>
|
||||||
|
[Authorize(Roles = "Admin")]
|
||||||
|
[HttpGet("admin/stats")]
|
||||||
|
[ProducesResponseType(typeof(AdminDashboardStatsDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
public async Task<ActionResult<AdminDashboardStatsDto>> AdminStats() =>
|
||||||
|
Ok(await _users.GetAdminDashboardStatsAsync());
|
||||||
|
|
||||||
/// <summary>Получить список записей пользователя на лекции.</summary>
|
/// <summary>Получить список записей пользователя на лекции.</summary>
|
||||||
/// <remarks>Только Admin. Для текущего пользователя используйте GET /api/v1/users/me/enrollments.</remarks>
|
/// <remarks>Только Admin. Для текущего пользователя используйте GET /api/v1/users/me/enrollments.</remarks>
|
||||||
/// <param name="id">ID пользователя.</param>
|
/// <param name="id">ID пользователя.</param>
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
|
||||||
|
namespace UniVerse.Api.Middleware;
|
||||||
|
|
||||||
|
public sealed class LocalNetworksOnlyMiddleware
|
||||||
|
{
|
||||||
|
private readonly RequestDelegate _next;
|
||||||
|
private readonly ILogger<LocalNetworksOnlyMiddleware> _logger;
|
||||||
|
|
||||||
|
public LocalNetworksOnlyMiddleware(RequestDelegate next, ILogger<LocalNetworksOnlyMiddleware> logger)
|
||||||
|
{
|
||||||
|
_next = next;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InvokeAsync(HttpContext context)
|
||||||
|
{
|
||||||
|
var remoteIpAddress = context.Connection.RemoteIpAddress;
|
||||||
|
|
||||||
|
if (remoteIpAddress is null || !IsLocalNetwork(remoteIpAddress))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Blocked metrics request from non-local address {RemoteIpAddress}", remoteIpAddress);
|
||||||
|
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||||
|
await context.Response.WriteAsync("Metrics endpoint is available only from local networks.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _next(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsLocalNetwork(IPAddress ipAddress)
|
||||||
|
{
|
||||||
|
if (IPAddress.IsLoopback(ipAddress))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ipAddress.IsIPv4MappedToIPv6)
|
||||||
|
{
|
||||||
|
ipAddress = ipAddress.MapToIPv4();
|
||||||
|
}
|
||||||
|
|
||||||
|
return ipAddress.AddressFamily switch
|
||||||
|
{
|
||||||
|
AddressFamily.InterNetwork => IsPrivateOrLinkLocalIPv4(ipAddress),
|
||||||
|
AddressFamily.InterNetworkV6 => IsPrivateOrLinkLocalIPv6(ipAddress),
|
||||||
|
_ => false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsPrivateOrLinkLocalIPv4(IPAddress ipAddress)
|
||||||
|
{
|
||||||
|
var bytes = ipAddress.GetAddressBytes();
|
||||||
|
|
||||||
|
return bytes[0] == 10
|
||||||
|
|| bytes[0] == 127
|
||||||
|
|| (bytes[0] == 192 && bytes[1] == 168)
|
||||||
|
|| (bytes[0] == 172 && bytes[1] is >= 16 and <= 31)
|
||||||
|
|| (bytes[0] == 169 && bytes[1] == 254);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsPrivateOrLinkLocalIPv6(IPAddress ipAddress)
|
||||||
|
{
|
||||||
|
var bytes = ipAddress.GetAddressBytes();
|
||||||
|
|
||||||
|
return ipAddress.IsIPv6LinkLocal
|
||||||
|
|| ipAddress.IsIPv6SiteLocal
|
||||||
|
|| (bytes[0] & 0xfe) == 0xfc;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Authentication.JwtBearer;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
using Microsoft.OpenApi;
|
using Microsoft.OpenApi;
|
||||||
|
using Prometheus;
|
||||||
using Quartz;
|
using Quartz;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
using UniVerse.Api.BackgroundServices;
|
using UniVerse.Api.BackgroundServices;
|
||||||
@@ -221,6 +222,7 @@ if (app.Environment.IsDevelopment())
|
|||||||
app.UseCors();
|
app.UseCors();
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
|
app.UseHttpMetrics();
|
||||||
if (app.Environment.IsDevelopment())
|
if (app.Environment.IsDevelopment())
|
||||||
{
|
{
|
||||||
app.UseAntiforgery();
|
app.UseAntiforgery();
|
||||||
@@ -228,4 +230,10 @@ if (app.Environment.IsDevelopment())
|
|||||||
}
|
}
|
||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
|
|
||||||
|
// Restrict Prometheus scrape endpoint to local and private networks.
|
||||||
|
app.UseWhen(
|
||||||
|
context => context.Request.Path.StartsWithSegments("/metrics", StringComparison.OrdinalIgnoreCase),
|
||||||
|
branch => branch.UseMiddleware<LocalNetworksOnlyMiddleware>());
|
||||||
|
app.MapMetrics();
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
|
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
|
||||||
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.1" />
|
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.1" />
|
||||||
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.18.1" />
|
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.18.1" />
|
||||||
|
<PackageReference Include="prometheus-net.AspNetCore" Version="8.2.1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -4158,6 +4158,52 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/users/admin/stats": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Users"
|
||||||
|
],
|
||||||
|
"summary": "Получить статистику для админского дашборда.",
|
||||||
|
"description": "Только Admin.\n\n**Required roles:** Admin",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Агрегированная статистика дашборда.",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/AdminDashboardStatsDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Требуется аутентификация.",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDetails"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Требуется роль Admin.",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDetails"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"Bearer": [ ]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/users/{id}/enrollments": {
|
"/api/v1/users/{id}/enrollments": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -4758,6 +4804,28 @@
|
|||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
|
"AdminDashboardStatsDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"usersCount": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"lecturesCount": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"enrollmentsCount": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"pendingReviewsCount": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
"AuthResponse": {
|
"AuthResponse": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<Project Sdk="Aspire.AppHost.Sdk/13.2.2">
|
<Project Sdk="Aspire.AppHost.Sdk/13.3.5">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<OutputType>Exe</OutputType>
|
<OutputType>Exe</OutputType>
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Aspire.Hosting.AppHost" Version="13.2.2" />
|
<PackageReference Include="Aspire.Hosting.AppHost" Version="13.3.5" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -42,6 +42,13 @@ public record UserStatsDto(
|
|||||||
IReadOnlyList<EnrollmentSlotRuleDto> EnrollmentSlotRules
|
IReadOnlyList<EnrollmentSlotRuleDto> EnrollmentSlotRules
|
||||||
);
|
);
|
||||||
|
|
||||||
|
public record AdminDashboardStatsDto(
|
||||||
|
int UsersCount,
|
||||||
|
int LecturesCount,
|
||||||
|
int EnrollmentsCount,
|
||||||
|
int PendingReviewsCount
|
||||||
|
);
|
||||||
|
|
||||||
public record EnrollmentSlotRuleDto(int Level, int Slots);
|
public record EnrollmentSlotRuleDto(int Level, int Slots);
|
||||||
|
|
||||||
public record UpdateUserRequest(
|
public record UpdateUserRequest(
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ public interface IUserService
|
|||||||
Task<UserDto> GetByIdAsync(int id);
|
Task<UserDto> GetByIdAsync(int id);
|
||||||
Task<UserDto> UpdateProfileAsync(int id, UpdateUserRequest request);
|
Task<UserDto> UpdateProfileAsync(int id, UpdateUserRequest request);
|
||||||
Task<UserStatsDto> GetStatsAsync(int id);
|
Task<UserStatsDto> GetStatsAsync(int id);
|
||||||
|
Task<AdminDashboardStatsDto> GetAdminDashboardStatsAsync();
|
||||||
Task<PagedResult<LectureDto>> GetEnrollmentsAsync(int id, PaginationRequest pagination);
|
Task<PagedResult<LectureDto>> GetEnrollmentsAsync(int id, PaginationRequest pagination);
|
||||||
Task<PagedResult<UserDto>> GetAllAsync(UserFilterRequest filter);
|
Task<PagedResult<UserDto>> GetAllAsync(UserFilterRequest filter);
|
||||||
Task SetRolesAsync(int id, IReadOnlyCollection<UserRole> roles);
|
Task SetRolesAsync(int id, IReadOnlyCollection<UserRole> roles);
|
||||||
|
|||||||
@@ -74,6 +74,17 @@ public class UserService : IUserService
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<AdminDashboardStatsDto> GetAdminDashboardStatsAsync()
|
||||||
|
{
|
||||||
|
var usersCount = await _db.Users
|
||||||
|
.CountAsync(user => !user.Roles.Any(role => role.Role == UserRole.Teacher));
|
||||||
|
var lecturesCount = await _db.Lectures.CountAsync();
|
||||||
|
var enrollmentsCount = await _db.LectureEnrollments.CountAsync();
|
||||||
|
var pendingReviewsCount = await _db.Reviews.CountAsync(review => review.LlmStatus == ReviewLlmStatus.Pending);
|
||||||
|
|
||||||
|
return new AdminDashboardStatsDto(usersCount, lecturesCount, enrollmentsCount, pendingReviewsCount);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<PagedResult<LectureDto>> GetEnrollmentsAsync(int id, PaginationRequest pagination)
|
public async Task<PagedResult<LectureDto>> GetEnrollmentsAsync(int id, PaginationRequest pagination)
|
||||||
{
|
{
|
||||||
if (!await _db.Users.AnyAsync(u => u.Id == id))
|
if (!await _db.Users.AnyAsync(u => u.Id == id))
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import type {
|
|||||||
TagDto,
|
TagDto,
|
||||||
UpdateReviewPromptRequest,
|
UpdateReviewPromptRequest,
|
||||||
UserAchievementDto,
|
UserAchievementDto,
|
||||||
|
AdminDashboardStatsDto,
|
||||||
CurrentUserDto,
|
CurrentUserDto,
|
||||||
UserDto,
|
UserDto,
|
||||||
UserQuery,
|
UserQuery,
|
||||||
@@ -68,6 +69,7 @@ export const usersApi = {
|
|||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
}),
|
}),
|
||||||
myStats: () => apiRequest<UserStatsDto>('/users/me/stats'),
|
myStats: () => apiRequest<UserStatsDto>('/users/me/stats'),
|
||||||
|
adminStats: () => apiRequest<AdminDashboardStatsDto>('/users/admin/stats'),
|
||||||
async myEnrollments() {
|
async myEnrollments() {
|
||||||
const payload = await apiRequest<PagedResult<LectureDto> | LectureDto[] | undefined>(
|
const payload = await apiRequest<PagedResult<LectureDto> | LectureDto[] | undefined>(
|
||||||
'/users/me/enrollments',
|
'/users/me/enrollments',
|
||||||
|
|||||||
@@ -76,6 +76,13 @@ export interface UserStatsDto {
|
|||||||
enrollmentSlotRules: EnrollmentSlotRuleDto[]
|
enrollmentSlotRules: EnrollmentSlotRuleDto[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AdminDashboardStatsDto {
|
||||||
|
usersCount: number
|
||||||
|
lecturesCount: number
|
||||||
|
enrollmentsCount: number
|
||||||
|
pendingReviewsCount: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface EnrollmentSlotRuleDto {
|
export interface EnrollmentSlotRuleDto {
|
||||||
level: number
|
level: number
|
||||||
slots: number
|
slots: number
|
||||||
|
|||||||
@@ -3,17 +3,11 @@ import { computed, onMounted, ref } from 'vue'
|
|||||||
import GlassCard from '@/components/ui/GlassCard.vue'
|
import GlassCard from '@/components/ui/GlassCard.vue'
|
||||||
import StatsWidget from '@/components/ui/StatsWidget.vue'
|
import StatsWidget from '@/components/ui/StatsWidget.vue'
|
||||||
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
||||||
import { lecturesApi, reviewsApi, syncApi, usersApi } from '@/api'
|
import { syncApi, usersApi } from '@/api'
|
||||||
import type { LectureDto, SyncStatusDto, UserDto } from '@/api/types'
|
import type { AdminDashboardStatsDto, SyncStatusDto } from '@/api/types'
|
||||||
|
|
||||||
const users = ref<UserDto[]>([])
|
const stats = ref<AdminDashboardStatsDto | null>(null)
|
||||||
const lectures = ref<LectureDto[]>([])
|
|
||||||
const pendingReviewsCount = ref(0)
|
|
||||||
const syncStatus = ref<SyncStatusDto | null>(null)
|
const syncStatus = ref<SyncStatusDto | null>(null)
|
||||||
|
|
||||||
const enrollmentCount = computed(() =>
|
|
||||||
lectures.value.reduce((sum, lecture) => sum + lecture.enrollmentsCount, 0),
|
|
||||||
)
|
|
||||||
const syncMeta = computed(() =>
|
const syncMeta = computed(() =>
|
||||||
syncStatus.value?.lastSyncAt
|
syncStatus.value?.lastSyncAt
|
||||||
? `Последняя синхронизация: ${new Date(syncStatus.value.lastSyncAt).toLocaleString('ru-RU')}`
|
? `Последняя синхронизация: ${new Date(syncStatus.value.lastSyncAt).toLocaleString('ru-RU')}`
|
||||||
@@ -21,16 +15,8 @@ const syncMeta = computed(() =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const [usersResult, lecturesResult, reviewsResult, syncResult] = await Promise.allSettled([
|
const [statsResult, syncResult] = await Promise.allSettled([usersApi.adminStats(), syncApi.status()])
|
||||||
usersApi.list({ PageSize: 100 }),
|
if (statsResult.status === 'fulfilled') stats.value = statsResult.value
|
||||||
lecturesApi.list({ PageSize: 100 }),
|
|
||||||
reviewsApi.listPage({ Page: 1, PageSize: 1, LlmStatus: 'Pending' }),
|
|
||||||
syncApi.status(),
|
|
||||||
])
|
|
||||||
if (usersResult.status === 'fulfilled') users.value = usersResult.value
|
|
||||||
if (lecturesResult.status === 'fulfilled') lectures.value = lecturesResult.value
|
|
||||||
if (reviewsResult.status === 'fulfilled')
|
|
||||||
pendingReviewsCount.value = reviewsResult.value.totalCount
|
|
||||||
if (syncResult.status === 'fulfilled') syncStatus.value = syncResult.value
|
if (syncResult.status === 'fulfilled') syncStatus.value = syncResult.value
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -40,12 +26,17 @@ onMounted(async () => {
|
|||||||
<h1 class="page-title">Дашборд администратора</h1>
|
<h1 class="page-title">Дашборд администратора</h1>
|
||||||
|
|
||||||
<div class="stats-row">
|
<div class="stats-row">
|
||||||
<StatsWidget label="Пользователей" :value="users.length" icon="users" color="green" />
|
<StatsWidget label="Пользователей" :value="stats?.usersCount ?? 0" icon="users" color="green" />
|
||||||
<StatsWidget label="Лекций" :value="lectures.length" icon="books" color="aqua" />
|
<StatsWidget label="Лекций" :value="stats?.lecturesCount ?? 0" icon="books" color="aqua" />
|
||||||
<StatsWidget label="Записей" :value="enrollmentCount" icon="calendar-event" color="orange" />
|
<StatsWidget
|
||||||
|
label="Записей"
|
||||||
|
:value="stats?.enrollmentsCount ?? 0"
|
||||||
|
icon="calendar-event"
|
||||||
|
color="orange"
|
||||||
|
/>
|
||||||
<StatsWidget
|
<StatsWidget
|
||||||
label="Отзывы на проверке"
|
label="Отзывы на проверке"
|
||||||
:value="pendingReviewsCount"
|
:value="stats?.pendingReviewsCount ?? 0"
|
||||||
icon="message-circle"
|
icon="message-circle"
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user