1
0
mirror of https://github.com/XFox111/bonch-calendar.git synced 2026-06-30 10:52:41 +03:00

feat!: active users stats and improved logging and healthcheck

This commit is contained in:
2026-05-22 09:40:19 +00:00
parent 6a2b6980f9
commit 7f88891429
20 changed files with 629 additions and 82 deletions
+2
View File
@@ -480,3 +480,5 @@ $RECYCLE.BIN/
# Vim temporary swap files
*.swp
core
+14 -1
View File
@@ -5,6 +5,11 @@ Accept: application/json
###
GET {{Host}}/stats
Accept: application/json
###
GET {{Host}}/faculties
Accept: application/json
@@ -12,12 +17,20 @@ Accept: application/json
GET {{Host}}/groups
?facultyId=56682
&course=2
&year=2
Accept: application/json
###
@groupId = 56606
@facultyId = 50029
# See remark for id
@id = download
GET {{Host}}/timetable/{{facultyId}}/{{groupId}}
?id={{id}}
Accept: text/calendar
# id parameter changes behavior for the calendar:
# - If not present, an additional event will be appended to the calendar (see Program.cs)
# - If set to "download", nothing will happen
# - If set to any other value, this request will be counted in active users stats (only once per ID until the next restart)
+5 -12
View File
@@ -3,24 +3,17 @@ using Microsoft.Extensions.Diagnostics.HealthChecks;
namespace BonchCalendar.Health;
public class ApiHealthCheck(ApiService groupService) : IHealthCheck
public class ApiHealthCheck(IssueTrackingService trackingService) : IHealthCheck
{
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context, CancellationToken cancellationToken = default
)
{
try
{
Dictionary<int, string> faculties = await groupService.GetFacultiesListAsync();
Dictionary<string, object> report = trackingService.GetReport();
if (faculties.Count > 0)
return HealthCheckResult.Healthy();
if (report.Count > 0)
return HealthCheckResult.Unhealthy(description: "We're having issues with fetching data from the timetable website.", data: report);
return HealthCheckResult.Degraded(description: "Timetable website looks to be up, but returned an empty list of faculties.");
}
catch (Exception ex)
{
return HealthCheckResult.Unhealthy(description: "Timetable website appears to be down.", exception: ex);
}
return HealthCheckResult.Healthy();
}
}
+108 -45
View File
@@ -5,9 +5,7 @@ using BonchCalendar.Health;
using BonchCalendar.Services;
using BonchCalendar.Utils;
using HealthChecks.UI.Client;
using Ical.Net;
using Ical.Net.CalendarComponents;
using Ical.Net.Serialization;
using Ical.Net.DataTypes;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.AspNetCore.Mvc;
@@ -27,6 +25,8 @@ builder.Services.AddProblemDetails(configure =>
});
builder.Services
.AddSingleton<IssueTrackingService>()
.AddScoped<TimetableService>()
.AddScoped<ApiService>()
.AddScoped<ParsingService>();
@@ -55,84 +55,147 @@ app.MapHealthChecks("/health", new HealthCheckOptions
});
ILogger<Program> logger = app.Services.GetRequiredService<ILogger<Program>>();
IssueTrackingService tracker = app.Services.GetRequiredService<IssueTrackingService>();
List<string> ids = [];
app.MapGet("/stats", () => Results.Ok(new StatsResponse(ids.Count)))
.WithName("GetStats")
.WithDescription("Get basic usage statistics.")
.Produces<StatsResponse>(StatusCodes.Status200OK);
app.MapGet("/faculties", async ([FromServices] ApiService apiService) =>
{
logger.LogInformation("Fetching faculties list.");
Dictionary<int, string> faculties = await apiService.GetFacultiesListAsync();
return Results.Ok(faculties);
try
{
Dictionary<int, string> faculties = await apiService.GetFacultiesListAsync();
logger.LogInformation("Fetched {Count} faculties.", faculties.Count);
tracker.TrackFacultyFetch(true);
return Results.Ok(faculties);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to fetch faculties list.");
tracker.TrackFacultyFetch(false);
return Results.Problem("Failed to fetch faculties list.", statusCode: StatusCodes.Status500InternalServerError);
}
})
.WithName("GetFaculties")
.WithDescription("Gets the list of faculties.")
.ProducesProblem(StatusCodes.Status500InternalServerError)
.Produces<Dictionary<int, string>>(StatusCodes.Status200OK);
app.MapGet("/groups", async ([FromServices] ApiService apiService, int facultyId, [Range(1, 5)] int course) =>
app.MapGet("/groups", async ([FromServices] ApiService apiService, int facultyId, [Range(0, 5)] int year) =>
{
logger.LogInformation("Fetching groups list for faculty {FacultyId} and course {Course}.", facultyId, course);
Dictionary<int, string> groups = await apiService.GetGroupsListAsync(facultyId, course);
return Results.Ok(groups);
try
{
Dictionary<int, string> groups = await apiService.GetGroupsListAsync(facultyId, year);
logger.LogInformation("Fetched {Count} groups (facultyId: {FacultyId}, year: {Year}).", groups.Count, facultyId, year);
tracker.TrackGroupFetch(facultyId, year, true);
return Results.Ok(groups);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to fetch groups list (facultyId: {FacultyId}, year: {Year}).", facultyId, year);
tracker.TrackGroupFetch(facultyId, year, false);
return Results.Problem(
"Failed to fetch groups list.",
statusCode: StatusCodes.Status500InternalServerError,
extensions: new Dictionary<string, object?>
{
["facultyId"] = facultyId,
["year"] = year
}
);
}
})
.WithName("GetGroups")
.WithDescription("Gets the list of groups for the specified faculty and course.")
.Produces<Dictionary<int, string>>(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status500InternalServerError)
.ProducesValidationProblem();
app.MapGet("/timetable/{facultyId}/{groupId}", async (
int facultyId, int groupId,
[FromServices] ApiService apiService,
[FromServices] ParsingService parsingService
int facultyId, int groupId, string? id,
[FromServices] TimetableService timetableService
) =>
{
logger.LogInformation("Generating timetable for group {GroupId} of faculty {FacultyId}.", groupId, facultyId);
string cacheFile = Path.Combine(Path.GetTempPath(), $"bonch_cal_{groupId}.ics");
string? content = await timetableService.TryServingFromCacheAsync(groupId);
bool hasId = !string.IsNullOrEmpty(id);
if (File.Exists(cacheFile) && (DateTime.UtcNow - File.GetLastWriteTimeUtc(cacheFile)).TotalHours < 6)
if (hasId && id is not "download" && !ids.Contains(id!))
ids.Add(id!);
if (content is not null && hasId)
{
if (args.Contains("--no-cache"))
logger.LogWarning("Cache disabled via --no-cache, regenerating timetable for group {GroupId}.", groupId);
else
{
logger.LogInformation("Serving timetable for group {GroupId} from cache.", groupId);
return Results.Text(await File.ReadAllTextAsync(cacheFile), contentType: "text/calendar");
}
logger.LogInformation("Serving timetable for {FacultyId}/{GroupId} from cache.", facultyId, groupId);
return Results.Text(content, contentType: "text/calendar");
}
try
{
DateTime semesterStartDate = await apiService.GetSemesterStartDateAsync(groupId);
string groupName = (await apiService.GetGroupsListAsync(facultyId, 0))[groupId];
string classesRaw = await apiService.GetScheduleDocumentAsync(groupId, TimetableType.Classes);
List<CalendarEvent> timetable = [.. parsingService.ParseGeneralTimetable(classesRaw, semesterStartDate, groupName)];
TimetableType[] types = [TimetableType.Attestations, TimetableType.Exams, TimetableType.ExamsForExtramural];
foreach (TimetableType type in types)
if (hasId)
content = await timetableService.GetTimetableAsync(facultyId, groupId, saveToCache: true);
else
{
classesRaw = await apiService.GetScheduleDocumentAsync(groupId, type);
timetable.AddRange(parsingService.ParseExamTimetable(classesRaw, groupName));
content = await timetableService.GetTimetableAsync(facultyId, groupId, saveToCache: false, transform: calendar =>
calendar.Events.Add(new()
{
Summary = "Важно: обновите календарь расписания",
Description = """
Ваша ссылка на календарь устарела. Пожалуйста, обновите ее чтобы продолжить пользоватся сервисом.
Новая ссылка позволит нам собирать статистику о количестве активных пользователей. Важно: мы НЕ собираем какие-либо персональные данные! Новая ссылка лишь позволит нам узнать точное количесво пользователей, что очень важно для продолжения работы сервиса.
Для того чтобы обновить ссылку:
1. Перейдите на сайт https://bonch.xfox111.net/
2. Повторите все действия что и при создании календаря
3. Удалите старый календарь
Просим прощения за доставленные неудобства.
Если возникнут вопросы обращайтесь на почту feedback@xfox111.net
Это событие будет появляться каждый день в 19:00 до тех пор, пока ссылка не будет обновлена.
""",
Location = "https://bonch.xfox111.net",
Start = new CalDateTime((DateTime.Today + TimeSpan.FromHours(16)).ToUniversalTime()),
End = new CalDateTime((DateTime.Today + TimeSpan.FromHours(16) + TimeSpan.FromMinutes(15)).ToUniversalTime()),
})
);
logger.LogInformation("Deprecation notice appended to calendar {FacultyId}/{GroupId}.", facultyId, groupId);
}
Calendar calendar = new();
calendar.Properties.Add(new CalendarProperty("X-WR-CALNAME", groupName));
calendar.Properties.Add(new CalendarProperty("X-WR-TIMEZONE", "Europe/Moscow"));
calendar.Properties.Add(new CalendarProperty("REFRESH-INTERVAL;VALUE=DURATION", "PT6H"));
calendar.Events.AddRange(timetable);
calendar.AddTimeZone(new VTimeZone("Europe/Moscow"));
string serialized = new CalendarSerializer().SerializeToString(calendar)!;
await File.WriteAllTextAsync(cacheFile, serialized);
logger.LogInformation("Cached timetable for group {GroupId} to {CacheFile}.", groupId, cacheFile);
return Results.Text(serialized, contentType: "text/calendar");
logger.LogInformation("Fetched timetable for {FacultyId}/{GroupId}.", facultyId, groupId);
tracker.TrackTimetableFetch(facultyId, groupId, true);
return Results.Text(content, contentType: "text/calendar");
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to generate timetable for group {GroupId} of faculty {FacultyId}.", groupId, facultyId);
throw;
logger.LogError(ex, "Failed to generate timetable for {FacultyId}/{GroupId}.", facultyId, groupId);
tracker.TrackTimetableFetch(facultyId, groupId, false);
if (content is not null)
{
logger.LogWarning("[Fallback] Serving timetable from cache ({CacheFile}).", cacheFile);
return Results.Text(content, contentType: "text/calendar");
}
return Results.Problem(
"Failed to fetch timetable",
statusCode: StatusCodes.Status500InternalServerError,
extensions: new Dictionary<string, object?>
{
["facultyId"] = facultyId,
["groupId"] = groupId
}
);
}
})
.WithName("GetTimetable")
.WithDescription("Gets the iCal timetable for the specified group.")
.Produces<string>(StatusCodes.Status200OK, "text/calendar")
.ProducesProblem(StatusCodes.Status500InternalServerError)
.ProducesValidationProblem();
app.Run();
+55
View File
@@ -0,0 +1,55 @@
namespace BonchCalendar.Services;
public class IssueTrackingService
{
private bool _isLastFacultyFetchSuccessful = true;
private readonly List<string> _unsuccessfulGroupFetches = [];
private readonly List<string> _unsuccessfulTimetableFetches = [];
public void TrackFacultyFetch(bool isSuccessful) =>
_isLastFacultyFetchSuccessful = isSuccessful;
public void TrackGroupFetch(int facultyId, int course, bool isSuccessful)
{
string key = $"{facultyId}/{course}";
if (!isSuccessful)
{
if (!_unsuccessfulGroupFetches.Contains(key))
_unsuccessfulGroupFetches.Add(key);
}
else
_unsuccessfulGroupFetches.Remove(key);
}
public void TrackTimetableFetch(int facultyId, int groupId, bool isSuccessful)
{
string key = $"{facultyId}/{groupId}";
if (!isSuccessful)
{
if (!_unsuccessfulTimetableFetches.Contains(key))
_unsuccessfulTimetableFetches.Add(key);
}
else
_unsuccessfulTimetableFetches.Remove(key);
}
public Dictionary<string, object> GetReport()
{
Dictionary<string, object> report = [];
if (!_isLastFacultyFetchSuccessful)
report.Add("/faculties", false);
if (_unsuccessfulGroupFetches.Count > 0)
report.Add("/groups", _unsuccessfulGroupFetches);
if (_unsuccessfulTimetableFetches.Count > 0)
report.Add("/timetable", _unsuccessfulTimetableFetches);
return report;
}
}
+2
View File
@@ -118,6 +118,8 @@ public partial class ParsingService
https://nav.sut.ru/?cab=k{auditoriumMatch.Groups["wing"].Value}-{auditoriumMatch.Groups["room"].Value}
""";
str += "\n\n" + "Создано при помощи сервиса Бонч.Календарь: https://bonch.xfox111.net";
return str;
}
+66
View File
@@ -0,0 +1,66 @@
using Ical.Net;
using Ical.Net.CalendarComponents;
using Ical.Net.Serialization;
namespace BonchCalendar.Services;
public class TimetableService(
ApiService apiService,
ParsingService parsingService,
ILogger<TimetableService> logger,
IHostEnvironment environment
)
{
public async Task<string?> TryServingFromCacheAsync(int groupId)
{
string cacheFile = GetCachePath(groupId);
if (!File.Exists(cacheFile) || (DateTime.UtcNow - File.GetLastWriteTimeUtc(cacheFile)).TotalHours >= 6)
return null;
if (environment.IsDevelopment())
{
logger.LogWarning("Caching is disabled for development environment.");
return null;
}
return await File.ReadAllTextAsync(cacheFile);
}
public async Task<string> GetTimetableAsync(int facultyId, int groupId, bool saveToCache = true, Action<Calendar>? transform = null)
{
DateTime semesterStartDate = await apiService.GetSemesterStartDateAsync(groupId);
string groupName = (await apiService.GetGroupsListAsync(facultyId, 0))[groupId];
string classesRaw = await apiService.GetScheduleDocumentAsync(groupId, TimetableType.Classes);
List<CalendarEvent> timetable = [.. parsingService.ParseGeneralTimetable(classesRaw, semesterStartDate, groupName)];
TimetableType[] types = [TimetableType.Attestations, TimetableType.Exams, TimetableType.ExamsForExtramural];
foreach (TimetableType type in types)
{
classesRaw = await apiService.GetScheduleDocumentAsync(groupId, type);
timetable.AddRange(parsingService.ParseExamTimetable(classesRaw, groupName));
}
Calendar calendar = new();
calendar.AddTimeZone(new VTimeZone("Europe/Moscow"));
calendar.Properties.Add(new CalendarProperty("X-WR-CALNAME", groupName));
calendar.Properties.Add(new CalendarProperty("X-WR-TIMEZONE", "Europe/Moscow"));
calendar.Properties.Add(new CalendarProperty("REFRESH-INTERVAL;VALUE=DURATION", "PT6H"));
calendar.Events.AddRange(timetable);
transform?.Invoke(calendar);
string content = new CalendarSerializer().SerializeToString(calendar)!;
if (saveToCache)
{
string cacheFile = GetCachePath(groupId);
await File.WriteAllTextAsync(cacheFile, content);
logger.LogInformation("Cache updated: {CacheFile}", cacheFile);
}
return content;
}
private static string GetCachePath(int groupId) =>
Path.Combine(Path.GetTempPath(), $"bonch_cal_{groupId}.ics");
}
+3
View File
@@ -0,0 +1,3 @@
namespace BonchCalendar;
public record StatsResponse(int ActiveUsers);