diff --git a/src/frontend/main.py b/src/frontend/main.py index be8fd5a..ddc220e 100644 --- a/src/frontend/main.py +++ b/src/frontend/main.py @@ -1,4 +1,9 @@ +"""Фронтенд расписания: статики + прокси к API бэкенда.""" + +from __future__ import annotations + import os +import logging from pathlib import Path import requests @@ -8,17 +13,28 @@ from fastapi import FastAPI, HTTPException, Query from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles +log = logging.getLogger(__name__) +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") + load_dotenv(Path(__file__).parent / ".env") +# Бэкенд на холодном кэше скачивает таблицу (до 2 минут), таймаут выше. SCHEDULE_API = os.getenv("SCHEDULE_API", "http://127.0.0.1:8000").rstrip("/") +PROXY_TIMEOUT = float(os.getenv("SCHEDULE_PROXY_TIMEOUT", "150")) STATIC_DIR = Path(__file__).parent / "static" app = FastAPI(title="Расписание — фронт") -def _proxy(path: str, params: dict): +@app.get("/healthz") +def healthz() -> dict: + """Живость процесса; не зависит от бэкенда.""" + return {"ok": True} + + +def _proxy(path: str, params: dict) -> object: try: - resp = requests.get(f"{SCHEDULE_API}/{path}", params=params, timeout=30) + resp = requests.get(f"{SCHEDULE_API}/{path}", params=params, timeout=PROXY_TIMEOUT) except requests.RequestException as exc: raise HTTPException(status_code=502, detail=f"Бэкенд недоступен: {exc}") if resp.status_code != 200: @@ -30,54 +46,60 @@ def _proxy(path: str, params: dict): return resp.json() +# --- API-маршруты ----------------------------------------------------------- + + @app.get("/api/groups") -def api_groups(force: bool = Query(False)): - return _proxy("groups", {"force": force}) +def api_groups(): + return _proxy("groups", {}) @app.get("/api/schedule/{group}") -def api_schedule(group: str, force: bool = Query(False)): - return _proxy(f"schedule/{group}", {"force": force}) +def api_schedule(group: str): + return _proxy(f"schedule/{group}", {}) @app.get("/api/teachers") -def api_teachers(force: bool = Query(False)): - return _proxy("teachers", {"force": force}) +def api_teachers(): + return _proxy("teachers", {}) @app.get("/api/rooms") -def api_rooms(force: bool = Query(False)): - return _proxy("rooms", {"force": force}) +def api_rooms(): + return _proxy("rooms", {}) @app.get("/api/teacher/{name}") -def api_teacher(name: str, force: bool = Query(False)): - return _proxy(f"teacher/{name}", {"force": force}) +def api_teacher(name: str): + return _proxy(f"teacher/{name}", {}) @app.get("/api/room/{label}") -def api_room(label: str, force: bool = Query(False)): - return _proxy(f"room/{label}", {"force": force}) +def api_room(label: str): + return _proxy(f"room/{label}", {}) @app.get("/api/rooms-free") -def api_rooms_free(day: str, period: int, force: bool = Query(False)): - return _proxy("rooms-free", {"day": day, "period": period, "force": force}) +def api_rooms_free(day: str, period: int): + return _proxy("rooms-free", {"day": day, "period": period}) @app.get("/api/rooms-day") -def api_rooms_day(day: str, force: bool = Query(False)): - return _proxy("rooms-day", {"day": day, "force": force}) +def api_rooms_day(day: str): + return _proxy("rooms-day", {"day": day}) @app.get("/api/week") -def api_week(force: bool = Query(False)): - return _proxy("week", {"force": force}) +def api_week(): + return _proxy("week", {}) @app.get("/api/status") -def api_status(force: bool = Query(False)): - return _proxy("status", {"force": force}) +def api_status(): + return _proxy("status", {}) + + +# --- статики ----------------------------------------------------------------- @app.get("/") @@ -89,5 +111,4 @@ app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static") if __name__ == "__main__": - uvicorn.run(app, host="127.0.0.1", port=8080) - + uvicorn.run("main:app", host="127.0.0.1", port=8080, reload=True) diff --git a/src/frontend/static/app.js b/src/frontend/static/app.js index a63a06e..273ea19 100644 --- a/src/frontend/static/app.js +++ b/src/frontend/static/app.js @@ -19,6 +19,8 @@ const PARITY_CLASS = { ч: "badge badge-ch", з: "badge badge-zn" }; let currentParity = null; let mode = "groups"; +let requestSeq = 0; +const FETCH_TIMEOUT_MS = 30000; function todayName() { return RUSSIAN_DAYS[new Date().getDay()]; @@ -29,7 +31,16 @@ function enc(value) { } async function fetchJSON(url) { - const resp = await fetch(url); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + let resp; + try { + resp = await fetch(url, { signal: controller.signal }); + } catch (err) { + throw new Error(err.name === "AbortError" ? "Время запроса истекло" : err.message); + } finally { + clearTimeout(timer); + } if (!resp.ok) { let detail = resp.statusText; try { @@ -44,7 +55,7 @@ async function fetchJSON(url) { async function loadWeek() { try { const data = await fetchJSON("/api/week"); - currentParity = data.parity; + if (data) currentParity = data.parity; } catch (_) {} } @@ -104,32 +115,37 @@ async function setMode(next) { await renderView(); } -async function renderView(force = false) { +async function renderView() { const value = groupSelect.value; if (!value) return; localStorage.setItem(MODES[mode].valueKey, value); + const seq = ++requestSeq; scheduleEl.innerHTML = ""; errorEl.classList.add("hidden"); loadingEl.classList.remove("hidden"); try { + let data, status; if (mode === "groups") { - const [data, status] = await Promise.all([ - fetchJSON(`/api/schedule/${enc(value)}${force ? "?force=1" : ""}`), + [data, status] = await Promise.all([ + fetchJSON(`/api/schedule/${enc(value)}`), loadStatus(), ]); + if (seq !== requestSeq) return; renderGroupSchedule(data, status); } else { - const [data, status] = await Promise.all([ + [data, status] = await Promise.all([ fetchJSON(`/api/teacher/${enc(value)}`), loadStatus(), ]); + if (seq !== requestSeq) return; renderTeacher(data, status); } } catch (err) { + if (seq !== requestSeq) return; errorEl.textContent = "Ошибка: " + err.message; errorEl.classList.remove("hidden"); } finally { - loadingEl.classList.add("hidden"); + if (seq === requestSeq) loadingEl.classList.add("hidden"); } } @@ -157,6 +173,11 @@ function statusRowHTML(status) { const synced = formatDT(status.last_updated); if (modified) row.appendChild(statusChip("Изменение таблицы", modified)); if (synced) row.appendChild(statusChip("Синхронизация", synced)); + if (status.error) { + const chip = statusChip("Ошибка", status.error); + chip.classList.add("error"); + row.appendChild(chip); + } } if (currentParity) { const chip = document.createElement("div"); @@ -371,11 +392,14 @@ const FREE_WEEKDAYS = ["понедельник", "вторник", "среда", async function renderRoomsView(day) { const chosenDay = FREE_WEEKDAYS.includes(day) ? day : (FREE_WEEKDAYS.includes(todayName()) ? todayName() : "понедельник"); + const seq = ++requestSeq; scheduleEl.innerHTML = ""; errorEl.classList.add("hidden"); loadingEl.classList.remove("hidden"); try { const status = await loadStatus(); + const data = await fetchJSON(`/api/rooms-day?day=${enc(chosenDay)}`); + if (seq !== requestSeq) return; scheduleEl.appendChild(Object.assign(document.createElement("div"), { className: "schedule-title", textContent: "Занятость аудиторий", @@ -386,14 +410,13 @@ async function renderRoomsView(day) { const wrap = document.createElement("div"); wrap.className = "rooms-wrap"; scheduleEl.appendChild(wrap); - - const data = await fetchJSON(`/api/rooms-day?day=${enc(chosenDay)}`); wrap.appendChild(roomsDayTable(data)); } catch (err) { + if (seq !== requestSeq) return; errorEl.textContent = "Ошибка: " + err.message; errorEl.classList.remove("hidden"); } finally { - loadingEl.classList.add("hidden"); + if (seq === requestSeq) loadingEl.classList.add("hidden"); } } diff --git a/src/frontend/static/style.css b/src/frontend/static/style.css index 8ff0c47..72723f1 100644 --- a/src/frontend/static/style.css +++ b/src/frontend/static/style.css @@ -323,6 +323,18 @@ main { color: var(--text); } +.status-chip.error { + background: color-mix(in srgb, var(--accent-red-bright) 12%, var(--surface-2)); + border-color: var(--accent-red-bright); +} + +.status-chip.error .status-value { + color: var(--accent-red-bright); + font-size: 13px; + line-height: 1.3; + word-break: break-word; +} + .current-week .badge { margin-right: 0; font-size: 12px; } .day-block {