7 Commits

Author SHA1 Message Date
serega404 6c49a821e7 Merge branch 'dev'
Backend CI / build-and-test (push) Successful in 52s
Frontend CI / build-and-check (push) Successful in 22s
Frontend Playwright / e2e (push) Successful in 10m31s
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 5s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 8s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 8s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Has been skipped
# Conflicts:
#	backend/UniVerse.Api/UniVerse.Api.csproj
2026-05-28 20:08:59 +03:00
serega404 4cc1478e86 Merge branch 'dev'
Frontend CI / build-and-check (push) Failing after 18s
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 5s
🚀 Create and publish a Docker image / Build & publish backend image (push) Has been skipped
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 8s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Has been skipped
2026-05-25 03:56:22 +03:00
serega404 6287661ca5 Merge pull request 'chore(deps): update dependency microsoft.aspnetcore.authentication.jwtbearer to 10.0.8' (#10) from renovate/microsoft.aspnetcore.authentication.jwtbearer-10.x into main
Backend CI / build-and-test (push) Successful in 41s
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 4s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 29s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Has been skipped
🚀 Create and publish a Docker image / Update stack on Portainer (push) Has been skipped
Reviewed-on: #10
2026-05-25 03:42:07 +03:00
Renovate Bot 8223697bd3 chore(deps): update dependency microsoft.aspnetcore.authentication.jwtbearer to 10.0.8
Backend CI / build-and-test (pull_request) Successful in 44s
2026-05-25 00:32:40 +00:00
serega404 3106f0ef61 Merge pull request 'Dev' (#11) from dev into main
Backend CI / build-and-test (push) Successful in 39s
Frontend CI / build-and-check (push) Failing after 5m18s
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 5s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 7s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Failing after 16s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Has been skipped
Reviewed-on: #11
2026-05-25 03:22:55 +03:00
serega404 a220afd078 Merge pull request 'chore: Configure Renovate' (#8) from renovate/configure into main
Create and publish a Docker image / Publish image (push) Successful in 2m23s
Reviewed-on: #8
2026-05-25 03:03:56 +03:00
Renovate Bot cd3f2c53b7 Add renovate.json 2026-05-25 00:02:52 +00:00
13 changed files with 26 additions and 206 deletions
@@ -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.5"> <PackageReference Include="xunit.runner.visualstudio" Version="3.1.0">
<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,19 +159,6 @@ 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>
@@ -1,71 +0,0 @@
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;
}
}
-8
View File
@@ -4,7 +4,6 @@ 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;
@@ -222,7 +221,6 @@ 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();
@@ -230,10 +228,4 @@ 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();
-1
View File
@@ -30,7 +30,6 @@
<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>
-68
View File
@@ -4158,52 +4158,6 @@
] ]
} }
}, },
"/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": [
@@ -4804,28 +4758,6 @@
}, },
"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.3.5"> <Project Sdk="Aspire.AppHost.Sdk/13.2.2">
<PropertyGroup> <PropertyGroup>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
@@ -13,7 +13,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Aspire.Hosting.AppHost" Version="13.3.5" /> <PackageReference Include="Aspire.Hosting.AppHost" Version="13.2.2" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -42,13 +42,6 @@ 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,7 +10,6 @@ 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,17 +74,6 @@ 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))
-2
View File
@@ -18,7 +18,6 @@ import type {
TagDto, TagDto,
UpdateReviewPromptRequest, UpdateReviewPromptRequest,
UserAchievementDto, UserAchievementDto,
AdminDashboardStatsDto,
CurrentUserDto, CurrentUserDto,
UserDto, UserDto,
UserQuery, UserQuery,
@@ -69,7 +68,6 @@ 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',
-7
View File
@@ -76,13 +76,6 @@ 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
+23 -14
View File
@@ -3,11 +3,17 @@ 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 { syncApi, usersApi } from '@/api' import { lecturesApi, reviewsApi, syncApi, usersApi } from '@/api'
import type { AdminDashboardStatsDto, SyncStatusDto } from '@/api/types' import type { LectureDto, SyncStatusDto, UserDto } from '@/api/types'
const stats = ref<AdminDashboardStatsDto | null>(null) const users = ref<UserDto[]>([])
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')}`
@@ -15,8 +21,16 @@ const syncMeta = computed(() =>
) )
onMounted(async () => { onMounted(async () => {
const [statsResult, syncResult] = await Promise.allSettled([usersApi.adminStats(), syncApi.status()]) const [usersResult, lecturesResult, reviewsResult, syncResult] = await Promise.allSettled([
if (statsResult.status === 'fulfilled') stats.value = statsResult.value usersApi.list({ PageSize: 100 }),
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>
@@ -26,17 +40,12 @@ onMounted(async () => {
<h1 class="page-title">Дашборд администратора</h1> <h1 class="page-title">Дашборд администратора</h1>
<div class="stats-row"> <div class="stats-row">
<StatsWidget label="Пользователей" :value="stats?.usersCount ?? 0" icon="users" color="green" /> <StatsWidget label="Пользователей" :value="users.length" icon="users" color="green" />
<StatsWidget label="Лекций" :value="stats?.lecturesCount ?? 0" icon="books" color="aqua" /> <StatsWidget label="Лекций" :value="lectures.length" icon="books" color="aqua" />
<StatsWidget <StatsWidget label="Записей" :value="enrollmentCount" icon="calendar-event" color="orange" />
label="Записей"
:value="stats?.enrollmentsCount ?? 0"
icon="calendar-event"
color="orange"
/>
<StatsWidget <StatsWidget
label="Отзывы на проверке" label="Отзывы на проверке"
:value="stats?.pendingReviewsCount ?? 0" :value="pendingReviewsCount"
icon="message-circle" icon="message-circle"
color="purple" color="purple"
/> />