From 7f8889142982eae45ddcbca4165ab0b02befb263 Mon Sep 17 00:00:00 2001 From: Eugene Fox Date: Fri, 22 May 2026 09:40:19 +0000 Subject: [PATCH] feat!: active users stats and improved logging and healthcheck --- api/.gitignore | 2 + api/BonchCalendar.http | 15 ++- api/Health/ApiHealthCheck.cs | 17 +-- api/Program.cs | 153 +++++++++++++++------- api/Services/IssueTrackingService.cs | 55 ++++++++ api/Services/ParsingService.cs | 2 + api/Services/TimetableService.cs | 66 ++++++++++ api/StatsResponse.cs | 3 + app/.env | 3 +- app/package-lock.json | 22 +++- app/package.json | 3 +- app/src/App.tsx | 2 + app/src/hooks/useTheme.ts | 1 + app/src/utils/api.ts | 71 ++++++++-- app/src/utils/strings.ts | 40 +++++- app/src/utils/tryFormatNamesForReport.tsx | 67 ++++++++++ app/src/views/MainView.styles.ts | 2 +- app/src/views/MainView.tsx | 14 +- app/src/views/StatsView.styles.ts | 44 +++++++ app/src/views/StatsView.tsx | 129 ++++++++++++++++++ 20 files changed, 629 insertions(+), 82 deletions(-) create mode 100644 api/Services/IssueTrackingService.cs create mode 100644 api/Services/TimetableService.cs create mode 100644 api/StatsResponse.cs create mode 100644 app/src/utils/tryFormatNamesForReport.tsx create mode 100644 app/src/views/StatsView.styles.ts create mode 100644 app/src/views/StatsView.tsx diff --git a/api/.gitignore b/api/.gitignore index 0808c4a..476c11f 100644 --- a/api/.gitignore +++ b/api/.gitignore @@ -480,3 +480,5 @@ $RECYCLE.BIN/ # Vim temporary swap files *.swp + +core diff --git a/api/BonchCalendar.http b/api/BonchCalendar.http index 104c041..439d27a 100644 --- a/api/BonchCalendar.http +++ b/api/BonchCalendar.http @@ -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) diff --git a/api/Health/ApiHealthCheck.cs b/api/Health/ApiHealthCheck.cs index d3e9fe0..1f9bdb9 100644 --- a/api/Health/ApiHealthCheck.cs +++ b/api/Health/ApiHealthCheck.cs @@ -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 CheckHealthAsync( HealthCheckContext context, CancellationToken cancellationToken = default ) { - try - { - Dictionary faculties = await groupService.GetFacultiesListAsync(); + Dictionary 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(); } } diff --git a/api/Program.cs b/api/Program.cs index a2c508e..8f76c47 100644 --- a/api/Program.cs +++ b/api/Program.cs @@ -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() + .AddScoped() .AddScoped() .AddScoped(); @@ -55,84 +55,147 @@ app.MapHealthChecks("/health", new HealthCheckOptions }); ILogger logger = app.Services.GetRequiredService>(); +IssueTrackingService tracker = app.Services.GetRequiredService(); +List ids = []; + +app.MapGet("/stats", () => Results.Ok(new StatsResponse(ids.Count))) + .WithName("GetStats") + .WithDescription("Get basic usage statistics.") + .Produces(StatusCodes.Status200OK); app.MapGet("/faculties", async ([FromServices] ApiService apiService) => { - logger.LogInformation("Fetching faculties list."); - Dictionary faculties = await apiService.GetFacultiesListAsync(); - return Results.Ok(faculties); + try + { + Dictionary 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>(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 groups = await apiService.GetGroupsListAsync(facultyId, course); - return Results.Ok(groups); + try + { + Dictionary 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 + { + ["facultyId"] = facultyId, + ["year"] = year + } + ); + } }) .WithName("GetGroups") .WithDescription("Gets the list of groups for the specified faculty and course.") .Produces>(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 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 + { + ["facultyId"] = facultyId, + ["groupId"] = groupId + } + ); } }) .WithName("GetTimetable") .WithDescription("Gets the iCal timetable for the specified group.") .Produces(StatusCodes.Status200OK, "text/calendar") + .ProducesProblem(StatusCodes.Status500InternalServerError) .ProducesValidationProblem(); app.Run(); diff --git a/api/Services/IssueTrackingService.cs b/api/Services/IssueTrackingService.cs new file mode 100644 index 0000000..506fbd4 --- /dev/null +++ b/api/Services/IssueTrackingService.cs @@ -0,0 +1,55 @@ +namespace BonchCalendar.Services; + +public class IssueTrackingService +{ + private bool _isLastFacultyFetchSuccessful = true; + + private readonly List _unsuccessfulGroupFetches = []; + + private readonly List _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 GetReport() + { + Dictionary 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; + } +} diff --git a/api/Services/ParsingService.cs b/api/Services/ParsingService.cs index 10d7d78..d72cd05 100644 --- a/api/Services/ParsingService.cs +++ b/api/Services/ParsingService.cs @@ -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; } diff --git a/api/Services/TimetableService.cs b/api/Services/TimetableService.cs new file mode 100644 index 0000000..c589dfa --- /dev/null +++ b/api/Services/TimetableService.cs @@ -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 logger, + IHostEnvironment environment +) +{ + public async Task 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 GetTimetableAsync(int facultyId, int groupId, bool saveToCache = true, Action? 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 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"); +} diff --git a/api/StatsResponse.cs b/api/StatsResponse.cs new file mode 100644 index 0000000..bf2d580 --- /dev/null +++ b/api/StatsResponse.cs @@ -0,0 +1,3 @@ +namespace BonchCalendar; + +public record StatsResponse(int ActiveUsers); diff --git a/app/.env b/app/.env index 793f808..9fb1467 100644 --- a/app/.env +++ b/app/.env @@ -1 +1,2 @@ -VITE_BACKEND_HOST=https://api.bonch.xfox111.net +# VITE_BACKEND_HOST=https://api.bonch.xfox111.net +VITE_BACKEND_HOST=http://localhost:8080 diff --git a/app/package-lock.json b/app/package-lock.json index 74b7d76..055cd69 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -13,7 +13,8 @@ "@fluentui/react-motion-components-preview": "^0.15.4", "react": "^19.2.6", "react-dom": "^19.2.6", - "react-localization": "^2.0.6" + "react-localization": "^2.0.6", + "uuid": "^14.0.0" }, "devDependencies": { "@eslint/js": "^10.0.1", @@ -2856,9 +2857,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", "dependencies": { @@ -4306,6 +4307,19 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/uuid": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz", + "integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, "node_modules/vite": { "version": "8.0.13", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz", diff --git a/app/package.json b/app/package.json index 68b704d..476b9ff 100644 --- a/app/package.json +++ b/app/package.json @@ -15,7 +15,8 @@ "@fluentui/react-motion-components-preview": "^0.15.4", "react": "^19.2.6", "react-dom": "^19.2.6", - "react-localization": "^2.0.6" + "react-localization": "^2.0.6", + "uuid": "^14.0.0" }, "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/app/src/App.tsx b/app/src/App.tsx index 69f5f54..8347156 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -5,6 +5,7 @@ import MainView from "./views/MainView"; import FaqView from "./views/FaqView"; import DedicatedView from "./views/DedicatedView"; import FooterView from "./views/FooterView"; +import StatsView from "./views/StatsView"; export default function App(): ReactElement { @@ -15,6 +16,7 @@ export default function App(): ReactElement
+ diff --git a/app/src/hooks/useTheme.ts b/app/src/hooks/useTheme.ts index 6990b21..d24ffb6 100644 --- a/app/src/hooks/useTheme.ts +++ b/app/src/hooks/useTheme.ts @@ -16,6 +16,7 @@ const baseTheme: Partial = colorBrandBackground: "#f68b1f", colorBrandBackgroundHover: "#c36e18", colorNeutralForeground2BrandHover: "#c36e18", + colorNeutralForeground2BrandPressed: "#a95f15", colorBrandBackgroundPressed: "#a95f15", colorCompoundBrandStroke: "#f68b1f", colorCompoundBrandStrokePressed: "#a95f15" diff --git a/app/src/utils/api.ts b/app/src/utils/api.ts index 980c7d0..be5743d 100644 --- a/app/src/utils/api.ts +++ b/app/src/utils/api.ts @@ -1,11 +1,64 @@ -export const fetchFaculties = async (): Promise<[string, string][]> => -{ - const res = await fetch(import.meta.env.VITE_BACKEND_HOST + "/faculties"); - return Object.entries(await res.json()); -}; +const timeout: number = 5000; -export const fetchGroups = async (facultyId: string, course: number): Promise<[string, string][]> => +export const fetchFaculties = (): Promise> => + fetchApi("/faculties", {}); + +export const fetchGroups = (facultyId: string, year: number): Promise> => + fetchApi(`/groups?facultyId=${facultyId}&year=${year}`, {}); + +export const fetchStats = async (): Promise => + fetchApi("/stats", { + activeUsers: 0 + }); + +export const fetchHealth = async (): Promise => + fetchApi("/health", {} as HealthResponse, true); + +async function fetchApi(path: string, defaultValue: T, alwaysReturnResponse: boolean = false): Promise { - const res = await fetch(`${import.meta.env.VITE_BACKEND_HOST}/groups?facultyId=${facultyId}&course=${course}`); - return Object.entries(await res.json()); -}; + try + { + const res = await fetch(import.meta.env.VITE_BACKEND_HOST + path, { + signal: AbortSignal.timeout(timeout) + }); + + if (!res.ok && !alwaysReturnResponse) + return defaultValue; + + return await res.json() + } + catch + { + return defaultValue; + } +} + +export type StatsResponse = + { + activeUsers: number; + }; + +export type HealthResponse = + { + status: ServiceStatus; + totalDuration: string; + entries: { + ["timetable_website"]: TimetableHealth; + }; + }; + +export type ServiceStatus = "Healthy" | "Unhealthy" | "Degraded"; + +export type TimetableHealth = + { + description?: string; + duration: string; + status: "Healthy" | "Unhealthy", + tags: unknown[], + data: + { + "/faculties"?: false, + "/groups"?: string[], + "/timetable"?: string[]; + }; + }; diff --git a/app/src/utils/strings.ts b/app/src/utils/strings.ts index 3711b7c..454e2fe 100644 --- a/app/src/utils/strings.ts +++ b/app/src/utils/strings.ts @@ -9,15 +9,33 @@ const strings = new LocalizedStrings({ subtitle_p1: "Check your SPbSUT classes in {0} calendar", subtitle_p2: "your", pickFaculty: "1. Pick your faculty", - pickCourse: "2. Pick your course", + pickCourse: "2. Pick your year", pickGroup: "3. Pick your group", - pickGroup_empty: "No groups are available for the selected course", + pickGroup_empty: "No groups are available for the selected year", subscribe: "4. Subscribe to the calendar", copy: "Copy link", or: "or", download: "Download .ics file", cta: "Like the service? Tell your classmates!", + // StatsView.tsx + users: "Active users: {0}", + status_ok: "Status: Operational", + status_unhealthy: "Status: Degraded", + report_title: "Service status report", + report_close: "Close", + report_subtitle_ok: "Service operates normally", + report_subtitle_unhealthy: "Active issues: {0}", + report_issue_backend: "Unable to connect to service's backend application.", + report_issue_faculties: "Last attempt to fetch faculties list resulted in an error.", + report_issue_groups: "Last attempt to fetch groups for following faculties resulted in an error:", + report_issue_groups_item: "{0} ({1}), {2} year", + report_issue_groups_item_alt: "Faculty ID: {0}, {1} year", + report_issue_timetable: "Last attempt to fetch timetable for following groups resulted in an error:", + report_issue_timetable_item_alt1: "Group ID: {0}, {1} ({2})", + report_issue_timetable_item_alt2: "{0} ({1}), Faculty ID: {2}", + report_issue_timetable_item_alt3: "Group ID: {0}, Faculty ID: {1}", + // FaqView.tsx faq_h2: "Frequently asked questions", question1_h3: "How do I save timetable to my Outlook/Google calendar?", @@ -77,6 +95,24 @@ const strings = new LocalizedStrings({ download: "Скачай .ics файл", cta: "Понравился сервис? Расскажи одногруппникам!", + // StatsView.tsx + users: "Пользователей: {0}", + status_ok: "Статус сервиса", + status_unhealthy: "Статус сервиса", + report_title: "Состояние сервиса", + report_close: "Закрыть", + report_subtitle_ok: "Сервис работает в нормальном режиме", + report_subtitle_unhealthy: "Известных проблем: {0}", + report_issue_backend: "Ошибка при подключении к серверу приложения.", + report_issue_faculties: "Ошибка при попытке получить список факультетов.", + report_issue_groups: "Ошибка при попытке получить список групп для следующих факультетов:", + report_issue_groups_item: "{0} ({1}), {2} курс", + report_issue_groups_item_alt: "ID факультета: {0}, {1} курс", + report_issue_timetable: "Ошибка при попытке получить расписание для следующих групп:", + report_issue_timetable_item_alt1: "ID группы: {0}, {1} ({2})", + report_issue_timetable_item_alt2: "{0} ({1}), ID факультета: {2}", + report_issue_timetable_item_alt3: "ID группы: {0}, ID факультета: {1}", + // FaqView.tsx faq_h2: "Часто задаваемые вопросы", question1_h3: "Как сохранить расписание в Outlook/Google календарь?", diff --git a/app/src/utils/tryFormatNamesForReport.tsx b/app/src/utils/tryFormatNamesForReport.tsx new file mode 100644 index 0000000..b7f8e5d --- /dev/null +++ b/app/src/utils/tryFormatNamesForReport.tsx @@ -0,0 +1,67 @@ +import { type TimetableHealth, fetchFaculties, fetchGroups } from "./api"; +import strings from "./strings"; + +export async function tryFormatNamesForReport(report?: TimetableHealth): Promise +{ + if (report === undefined) + return report; + + if (report.status === "Healthy") + return report; + + const isGroupsDown: boolean = report.data["/groups"] !== undefined; + const isTimetableDown: boolean = report.data["/timetable"] !== undefined; + + if (!isGroupsDown && !isTimetableDown) + return report; + + let faculties: Record | undefined = undefined; + + try { faculties = await fetchFaculties(); } + catch { /* empty */ } + + const facultiesFormatted: string[] = []; + + if (report.data["/groups"] !== undefined) + for (const faculty of report.data["/groups"]) + { + const [facultyId, course] = faculty.split("/"); + + if (faculties?.[facultyId] === undefined) + facultiesFormatted.push(strings.formatString(strings.report_issue_groups_item_alt, facultyId, course) as string); + + else + facultiesFormatted.push(strings.formatString(strings.report_issue_groups_item, faculties[facultyId], facultyId, course) as string); + } + + const groups: Record> = {}; + const groupsFormatted: string[] = []; + + if (report.data["/timetable"] !== undefined) + for (const group of report.data["/timetable"]) + { + const [facultyId, groupId] = group.split("/"); + + if (groups[facultyId] === undefined) + try { groups[facultyId] = await fetchGroups(facultyId, 0); } + catch { /* empty */ } + + if (groups[facultyId]?.[groupId] !== undefined && faculties?.[facultyId] !== undefined) + groupsFormatted.push(`${groups[facultyId][groupId]} (${groupId}), ${faculties[facultyId]} (${facultyId})`); + else if (faculties?.[facultyId] !== undefined) + groupsFormatted.push(strings.formatString(strings.report_issue_timetable_item_alt1, groupId, faculties[facultyId], facultyId) as string) + else if (groups[facultyId]?.[groupId] !== undefined) + groupsFormatted.push(strings.formatString(strings.report_issue_timetable_item_alt2, groups[facultyId][groupId], groupId, facultyId) as string) + else + groupsFormatted.push(strings.formatString(strings.report_issue_timetable_item_alt3, groupId, facultyId) as string) + } + + return { + ...report, + data: { + ...report.data, + ["/groups"]: facultiesFormatted.length > 0 ? facultiesFormatted : undefined, + ["/timetable"]: groupsFormatted.length > 0 ? groupsFormatted : undefined + } + }; +} diff --git a/app/src/views/MainView.styles.ts b/app/src/views/MainView.styles.ts index f2241ce..1ca887c 100644 --- a/app/src/views/MainView.styles.ts +++ b/app/src/views/MainView.styles.ts @@ -8,7 +8,7 @@ const useStyles_MainView = makeStyles({ flexFlow: "column", gap: tokens.spacingVerticalXXXL, justifyContent: "center", - minHeight: "90vh", + minHeight: "85vh", alignItems: "center", padding: `${tokens.spacingVerticalL} ${tokens.spacingHorizontalL}`, diff --git a/app/src/views/MainView.tsx b/app/src/views/MainView.tsx index 8d8f115..d8820f5 100644 --- a/app/src/views/MainView.tsx +++ b/app/src/views/MainView.tsx @@ -8,8 +8,9 @@ import useTimeout from "../hooks/useTimeout"; import useStyles_MainView from "./MainView.styles"; import { fetchFaculties, fetchGroups } from "../utils/api"; import strings from "../utils/strings"; +import { v7 as uuid7 } from "uuid"; -const facultiesPromise = fetchFaculties(); +const facultiesPromise = fetchFaculties().then(Object.entries); const getEntryOrEmpty = (entries: [string, string][], key: string): string => entries.find(i => i[0] === key)?.[1] ?? ""; @@ -25,6 +26,7 @@ export default function MainView(): ReactElement const [groups, setGroups] = useState<[string, string][] | null>(null); const [groupId, setGroupId] = useState(""); + const id = uuid7(); const icalUrl = useMemo(() => `${import.meta.env.VITE_BACKEND_HOST}/timetable/${facultyId}/${groupId}`, [groupId, facultyId]); const [showCta, setShowCta] = useState(false); @@ -35,10 +37,10 @@ export default function MainView(): ReactElement const copyLink = useCallback((): void => { - navigator.clipboard.writeText(icalUrl); + navigator.clipboard.writeText(icalUrl + "?id=" + id); triggerCopy(); setShowCta(true); - }, [icalUrl, triggerCopy]); + }, [icalUrl, triggerCopy, id]); const onFacultySelect = useCallback((_: SelectionEvents, data: OptionOnSelectData): void => { @@ -59,7 +61,7 @@ export default function MainView(): ReactElement setCourse(courseNumber); setGroupId(""); setGroups(null); - fetchGroups(facultyId, courseNumber).then(setGroups); + fetchGroups(facultyId, courseNumber).then(Object.entries).then(setGroups); }, [course, facultyId]); return ( @@ -144,7 +146,7 @@ export default function MainView(): ReactElement : }> - { icalUrl } + { icalUrl + "?id=" + id } @@ -155,7 +157,7 @@ export default function MainView(): ReactElement appearance="subtle" icon={ } onClick={ () => setShowCta(true) } disabled={ groupId === "" } - href={ icalUrl }> + href={ icalUrl + "?id=download" }> { strings.download } diff --git a/app/src/views/StatsView.styles.ts b/app/src/views/StatsView.styles.ts new file mode 100644 index 0000000..cbba28f --- /dev/null +++ b/app/src/views/StatsView.styles.ts @@ -0,0 +1,44 @@ +import { makeStyles, tokens } from "@fluentui/react-components"; + +export const useStyles = makeStyles({ + root: + { + display: "flex", + flexFlow: "column", + marginBottom: "80px" + }, + container: + { + display: "flex", + padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalMNudge}`, + gap: tokens.spacingHorizontalMNudge, + boxShadow: tokens.shadow4, + borderRadius: tokens.borderRadiusMedium + }, + statsButton: + { + pointerEvents: "none" + }, + statsButtonIcon: + { + color: tokens.colorBrandForeground1 + }, + statusIconHealthy: + { + color: tokens.colorStatusSuccessBorderActive, + }, + statusIconUnhealthy: + { + color: tokens.colorStatusDangerBorderActive, + }, + reportSubtitle: + { + display: "flex", + alignItems: "center", + gap: tokens.spacingHorizontalS + }, + reportContent: + { + userSelect: "text" + } +}); diff --git a/app/src/views/StatsView.tsx b/app/src/views/StatsView.tsx new file mode 100644 index 0000000..02dab02 --- /dev/null +++ b/app/src/views/StatsView.tsx @@ -0,0 +1,129 @@ +import { Button, Dialog, DialogBody, DialogContent, DialogSurface, DialogTitle, DialogTrigger, Divider, Subtitle2 } from "@fluentui/react-components"; +import { ArrowTrendingLinesFilled, CheckmarkCircleFilled, Dismiss24Regular, WarningFilled } from "@fluentui/react-icons"; +import { use, useMemo, type ReactElement } from "react"; +import { fetchHealth, fetchStats, type StatsResponse, type TimetableHealth } from "../utils/api"; +import strings from "../utils/strings"; +import { tryFormatNamesForReport } from "../utils/tryFormatNamesForReport"; +import { useStyles } from "./StatsView.styles"; + +const healthPromise = fetchHealth().then(i => i.entries?.["timetable_website"]).then(tryFormatNamesForReport); +const statsPromise = fetchStats(); + +export default function StatsView(): ReactElement +{ + const cls = useStyles(); + + const health: TimetableHealth | undefined = use(healthPromise); + const stats: StatsResponse = use(statsPromise); + + const issueCounter: number = useMemo(() => + { + let counter: number = 0; + + if (health === undefined) + return 1; + + if (health.data["/faculties"] !== undefined) + counter++; + + counter += health.data["/groups"]?.length ?? 0; + counter += health.data["/timetable"]?.length ?? 0; + + return counter; + }, [health]); + + return ( +
+
+ { stats.activeUsers > 3 && + <> + + + + } + + + { health?.status === "Healthy" ? + + : + + } + + + + + + +
+
+ ); +}