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 -1
View File
@@ -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
+18 -4
View File
@@ -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
View File
@@ -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",
+2
View File
@@ -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 />
+1
View File
@@ -16,6 +16,7 @@ const baseTheme: Partial<Theme> =
colorBrandBackground: "#f68b1f",
colorBrandBackgroundHover: "#c36e18",
colorNeutralForeground2BrandHover: "#c36e18",
colorNeutralForeground2BrandPressed: "#a95f15",
colorBrandBackgroundPressed: "#a95f15",
colorCompoundBrandStroke: "#f68b1f",
colorCompoundBrandStrokePressed: "#a95f15"
+62 -9
View File
@@ -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[];
};
};
+38 -2
View File
@@ -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 календарь?",
+67
View File
@@ -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
}
};
}
+1 -1
View File
@@ -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 -6
View File
@@ -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>
+44
View File
@@ -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"
}
});
+129
View File
@@ -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>
);
}