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:
@@ -480,3 +480,5 @@ $RECYCLE.BIN/
|
||||
|
||||
# Vim temporary swap files
|
||||
*.swp
|
||||
|
||||
core
|
||||
|
||||
+14
-1
@@ -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)
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace BonchCalendar;
|
||||
|
||||
public record StatsResponse(int ActiveUsers);
|
||||
@@ -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
|
||||
|
||||
Generated
+18
-4
@@ -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",
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
@@ -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
|
||||
<FluentProvider theme={ theme }>
|
||||
<main className={ cls.root }>
|
||||
<MainView />
|
||||
<StatsView />
|
||||
<FaqView />
|
||||
<DedicatedView />
|
||||
<FooterView />
|
||||
|
||||
@@ -16,6 +16,7 @@ const baseTheme: Partial<Theme> =
|
||||
colorBrandBackground: "#f68b1f",
|
||||
colorBrandBackgroundHover: "#c36e18",
|
||||
colorNeutralForeground2BrandHover: "#c36e18",
|
||||
colorNeutralForeground2BrandPressed: "#a95f15",
|
||||
colorBrandBackgroundPressed: "#a95f15",
|
||||
colorCompoundBrandStroke: "#f68b1f",
|
||||
colorCompoundBrandStrokePressed: "#a95f15"
|
||||
|
||||
+62
-9
@@ -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<Record<string, string>> =>
|
||||
fetchApi("/faculties", {});
|
||||
|
||||
export const fetchGroups = (facultyId: string, year: number): Promise<Record<string, string>> =>
|
||||
fetchApi(`/groups?facultyId=${facultyId}&year=${year}`, {});
|
||||
|
||||
export const fetchStats = async (): Promise<StatsResponse> =>
|
||||
fetchApi("/stats", {
|
||||
activeUsers: 0
|
||||
});
|
||||
|
||||
export const fetchHealth = async (): Promise<HealthResponse> =>
|
||||
fetchApi("/health", {} as HealthResponse, true);
|
||||
|
||||
async function fetchApi<T>(path: string, defaultValue: T, alwaysReturnResponse: boolean = false): Promise<T>
|
||||
{
|
||||
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[];
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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 календарь?",
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { type TimetableHealth, fetchFaculties, fetchGroups } from "./api";
|
||||
import strings from "./strings";
|
||||
|
||||
export async function tryFormatNamesForReport(report?: TimetableHealth): Promise<TimetableHealth | undefined>
|
||||
{
|
||||
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<string, string> | 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<string, Record<string, string>> = {};
|
||||
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
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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}`,
|
||||
|
||||
|
||||
@@ -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<string>("");
|
||||
|
||||
const id = uuid7();
|
||||
const icalUrl = useMemo(() => `${import.meta.env.VITE_BACKEND_HOST}/timetable/${facultyId}/${groupId}`, [groupId, facultyId]);
|
||||
|
||||
const [showCta, setShowCta] = useState<boolean>(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
|
||||
: <Copy24Regular className={ cls.copyIcon } />
|
||||
}>
|
||||
|
||||
<span className={ cls.truncatedText }>{ icalUrl }</span>
|
||||
<span className={ cls.truncatedText }>{ icalUrl + "?id=" + id }</span>
|
||||
</Button>
|
||||
</div>
|
||||
</Slide>
|
||||
@@ -155,7 +157,7 @@ export default function MainView(): ReactElement
|
||||
appearance="subtle" icon={ <ArrowDownload24Regular /> }
|
||||
onClick={ () => setShowCta(true) }
|
||||
disabled={ groupId === "" }
|
||||
href={ icalUrl }>
|
||||
href={ icalUrl + "?id=download" }>
|
||||
|
||||
{ strings.download }
|
||||
</Button>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
});
|
||||
@@ -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 (
|
||||
<div className={ cls.root }>
|
||||
<div className={ cls.container }>
|
||||
{ stats.activeUsers > 3 &&
|
||||
<>
|
||||
<Button
|
||||
className={ cls.statsButton } tabIndex={ -1 }
|
||||
icon={ <ArrowTrendingLinesFilled className={ cls.statsButtonIcon } /> }
|
||||
appearance="subtle"
|
||||
>
|
||||
{ strings.formatString(strings.users, stats.activeUsers) }
|
||||
</Button>
|
||||
<Divider vertical />
|
||||
</>
|
||||
}
|
||||
<Dialog>
|
||||
<DialogTrigger>
|
||||
{ health?.status === "Healthy" ?
|
||||
<Button icon={ <CheckmarkCircleFilled className={ cls.statusIconHealthy } /> } appearance="subtle">
|
||||
{ strings.status_ok }
|
||||
</Button>
|
||||
:
|
||||
<Button icon={ <WarningFilled className={ cls.statusIconUnhealthy } /> } appearance="subtle">
|
||||
{ strings.status_unhealthy }
|
||||
</Button>
|
||||
}
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogSurface>
|
||||
<DialogBody>
|
||||
<DialogTitle
|
||||
action={
|
||||
<DialogTrigger action="close">
|
||||
<Button
|
||||
appearance="subtle"
|
||||
aria-label={ strings.report_close }
|
||||
icon={ <Dismiss24Regular /> }
|
||||
/>
|
||||
</DialogTrigger>
|
||||
}
|
||||
>
|
||||
{ strings.report_title }
|
||||
</DialogTitle>
|
||||
<DialogContent className={ cls.reportContent }>
|
||||
{ health?.status === "Healthy" ?
|
||||
<div className={ cls.reportSubtitle }>
|
||||
<CheckmarkCircleFilled className={ cls.statusIconHealthy } fontSize={ 24 } />
|
||||
<Subtitle2>{ strings.report_subtitle_ok }</Subtitle2>
|
||||
</div>
|
||||
:
|
||||
<div className={ cls.reportSubtitle }>
|
||||
<WarningFilled className={ cls.statusIconUnhealthy } fontSize={ 24 } />
|
||||
<Subtitle2>
|
||||
{ strings.formatString(strings.report_subtitle_unhealthy, issueCounter) }
|
||||
</Subtitle2>
|
||||
</div>
|
||||
}
|
||||
{ health?.status !== "Healthy" &&
|
||||
<ul>
|
||||
{ health === undefined &&
|
||||
<li>{ strings.report_issue_backend }</li>
|
||||
}
|
||||
{ health?.data["/faculties"] !== undefined &&
|
||||
<li>{ strings.report_issue_faculties }</li>
|
||||
}
|
||||
{ health?.data["/groups"] !== undefined &&
|
||||
<li>
|
||||
{ strings.report_issue_groups }
|
||||
<ul>
|
||||
{ health.data["/groups"].map((i, index) =>
|
||||
<li key={ index }>{ i }</li>
|
||||
) }
|
||||
</ul>
|
||||
</li>
|
||||
}
|
||||
{ health?.data["/timetable"] !== undefined &&
|
||||
<li>
|
||||
{ strings.report_issue_timetable }
|
||||
<ul>
|
||||
{ health.data["/timetable"].map((i, index) =>
|
||||
<li key={ index }>{ i }</li>
|
||||
) }
|
||||
</ul>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</DialogContent>
|
||||
</DialogBody>
|
||||
</DialogSurface>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user