Фронтенд: стабильность прокси и рендера
- main.py: таймаут прокси 150с (холодная загрузка бэкенда до ~2мин), /healthz, чистый список маршрутов - app.js: таймаут fetch (AbortController), защита от гонок при переключении вкладок (requestSeq) - style.css: стили chипа ошибки кэша (multi-line заметки уже поддерживались)
This commit is contained in:
@@ -1,4 +1,9 @@
|
|||||||
|
"""Фронтенд расписания: статики + прокси к API бэкенда."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
@@ -8,17 +13,28 @@ from fastapi import FastAPI, HTTPException, Query
|
|||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
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")
|
load_dotenv(Path(__file__).parent / ".env")
|
||||||
|
|
||||||
|
# Бэкенд на холодном кэше скачивает таблицу (до 2 минут), таймаут выше.
|
||||||
SCHEDULE_API = os.getenv("SCHEDULE_API", "http://127.0.0.1:8000").rstrip("/")
|
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"
|
STATIC_DIR = Path(__file__).parent / "static"
|
||||||
|
|
||||||
app = FastAPI(title="Расписание — фронт")
|
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:
|
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:
|
except requests.RequestException as exc:
|
||||||
raise HTTPException(status_code=502, detail=f"Бэкенд недоступен: {exc}")
|
raise HTTPException(status_code=502, detail=f"Бэкенд недоступен: {exc}")
|
||||||
if resp.status_code != 200:
|
if resp.status_code != 200:
|
||||||
@@ -30,54 +46,60 @@ def _proxy(path: str, params: dict):
|
|||||||
return resp.json()
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
# --- API-маршруты -----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/groups")
|
@app.get("/api/groups")
|
||||||
def api_groups(force: bool = Query(False)):
|
def api_groups():
|
||||||
return _proxy("groups", {"force": force})
|
return _proxy("groups", {})
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/schedule/{group}")
|
@app.get("/api/schedule/{group}")
|
||||||
def api_schedule(group: str, force: bool = Query(False)):
|
def api_schedule(group: str):
|
||||||
return _proxy(f"schedule/{group}", {"force": force})
|
return _proxy(f"schedule/{group}", {})
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/teachers")
|
@app.get("/api/teachers")
|
||||||
def api_teachers(force: bool = Query(False)):
|
def api_teachers():
|
||||||
return _proxy("teachers", {"force": force})
|
return _proxy("teachers", {})
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/rooms")
|
@app.get("/api/rooms")
|
||||||
def api_rooms(force: bool = Query(False)):
|
def api_rooms():
|
||||||
return _proxy("rooms", {"force": force})
|
return _proxy("rooms", {})
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/teacher/{name}")
|
@app.get("/api/teacher/{name}")
|
||||||
def api_teacher(name: str, force: bool = Query(False)):
|
def api_teacher(name: str):
|
||||||
return _proxy(f"teacher/{name}", {"force": force})
|
return _proxy(f"teacher/{name}", {})
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/room/{label}")
|
@app.get("/api/room/{label}")
|
||||||
def api_room(label: str, force: bool = Query(False)):
|
def api_room(label: str):
|
||||||
return _proxy(f"room/{label}", {"force": force})
|
return _proxy(f"room/{label}", {})
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/rooms-free")
|
@app.get("/api/rooms-free")
|
||||||
def api_rooms_free(day: str, period: int, force: bool = Query(False)):
|
def api_rooms_free(day: str, period: int):
|
||||||
return _proxy("rooms-free", {"day": day, "period": period, "force": force})
|
return _proxy("rooms-free", {"day": day, "period": period})
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/rooms-day")
|
@app.get("/api/rooms-day")
|
||||||
def api_rooms_day(day: str, force: bool = Query(False)):
|
def api_rooms_day(day: str):
|
||||||
return _proxy("rooms-day", {"day": day, "force": force})
|
return _proxy("rooms-day", {"day": day})
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/week")
|
@app.get("/api/week")
|
||||||
def api_week(force: bool = Query(False)):
|
def api_week():
|
||||||
return _proxy("week", {"force": force})
|
return _proxy("week", {})
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/status")
|
@app.get("/api/status")
|
||||||
def api_status(force: bool = Query(False)):
|
def api_status():
|
||||||
return _proxy("status", {"force": force})
|
return _proxy("status", {})
|
||||||
|
|
||||||
|
|
||||||
|
# --- статики -----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
@@ -89,5 +111,4 @@ app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static")
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
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)
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ const PARITY_CLASS = { ч: "badge badge-ch", з: "badge badge-zn" };
|
|||||||
|
|
||||||
let currentParity = null;
|
let currentParity = null;
|
||||||
let mode = "groups";
|
let mode = "groups";
|
||||||
|
let requestSeq = 0;
|
||||||
|
const FETCH_TIMEOUT_MS = 30000;
|
||||||
|
|
||||||
function todayName() {
|
function todayName() {
|
||||||
return RUSSIAN_DAYS[new Date().getDay()];
|
return RUSSIAN_DAYS[new Date().getDay()];
|
||||||
@@ -29,7 +31,16 @@ function enc(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fetchJSON(url) {
|
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) {
|
if (!resp.ok) {
|
||||||
let detail = resp.statusText;
|
let detail = resp.statusText;
|
||||||
try {
|
try {
|
||||||
@@ -44,7 +55,7 @@ async function fetchJSON(url) {
|
|||||||
async function loadWeek() {
|
async function loadWeek() {
|
||||||
try {
|
try {
|
||||||
const data = await fetchJSON("/api/week");
|
const data = await fetchJSON("/api/week");
|
||||||
currentParity = data.parity;
|
if (data) currentParity = data.parity;
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,32 +115,37 @@ async function setMode(next) {
|
|||||||
await renderView();
|
await renderView();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderView(force = false) {
|
async function renderView() {
|
||||||
const value = groupSelect.value;
|
const value = groupSelect.value;
|
||||||
if (!value) return;
|
if (!value) return;
|
||||||
localStorage.setItem(MODES[mode].valueKey, value);
|
localStorage.setItem(MODES[mode].valueKey, value);
|
||||||
|
const seq = ++requestSeq;
|
||||||
scheduleEl.innerHTML = "";
|
scheduleEl.innerHTML = "";
|
||||||
errorEl.classList.add("hidden");
|
errorEl.classList.add("hidden");
|
||||||
loadingEl.classList.remove("hidden");
|
loadingEl.classList.remove("hidden");
|
||||||
try {
|
try {
|
||||||
|
let data, status;
|
||||||
if (mode === "groups") {
|
if (mode === "groups") {
|
||||||
const [data, status] = await Promise.all([
|
[data, status] = await Promise.all([
|
||||||
fetchJSON(`/api/schedule/${enc(value)}${force ? "?force=1" : ""}`),
|
fetchJSON(`/api/schedule/${enc(value)}`),
|
||||||
loadStatus(),
|
loadStatus(),
|
||||||
]);
|
]);
|
||||||
|
if (seq !== requestSeq) return;
|
||||||
renderGroupSchedule(data, status);
|
renderGroupSchedule(data, status);
|
||||||
} else {
|
} else {
|
||||||
const [data, status] = await Promise.all([
|
[data, status] = await Promise.all([
|
||||||
fetchJSON(`/api/teacher/${enc(value)}`),
|
fetchJSON(`/api/teacher/${enc(value)}`),
|
||||||
loadStatus(),
|
loadStatus(),
|
||||||
]);
|
]);
|
||||||
|
if (seq !== requestSeq) return;
|
||||||
renderTeacher(data, status);
|
renderTeacher(data, status);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (seq !== requestSeq) return;
|
||||||
errorEl.textContent = "Ошибка: " + err.message;
|
errorEl.textContent = "Ошибка: " + err.message;
|
||||||
errorEl.classList.remove("hidden");
|
errorEl.classList.remove("hidden");
|
||||||
} finally {
|
} 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);
|
const synced = formatDT(status.last_updated);
|
||||||
if (modified) row.appendChild(statusChip("Изменение таблицы", modified));
|
if (modified) row.appendChild(statusChip("Изменение таблицы", modified));
|
||||||
if (synced) row.appendChild(statusChip("Синхронизация", synced));
|
if (synced) row.appendChild(statusChip("Синхронизация", synced));
|
||||||
|
if (status.error) {
|
||||||
|
const chip = statusChip("Ошибка", status.error);
|
||||||
|
chip.classList.add("error");
|
||||||
|
row.appendChild(chip);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (currentParity) {
|
if (currentParity) {
|
||||||
const chip = document.createElement("div");
|
const chip = document.createElement("div");
|
||||||
@@ -371,11 +392,14 @@ const FREE_WEEKDAYS = ["понедельник", "вторник", "среда",
|
|||||||
|
|
||||||
async function renderRoomsView(day) {
|
async function renderRoomsView(day) {
|
||||||
const chosenDay = FREE_WEEKDAYS.includes(day) ? day : (FREE_WEEKDAYS.includes(todayName()) ? todayName() : "понедельник");
|
const chosenDay = FREE_WEEKDAYS.includes(day) ? day : (FREE_WEEKDAYS.includes(todayName()) ? todayName() : "понедельник");
|
||||||
|
const seq = ++requestSeq;
|
||||||
scheduleEl.innerHTML = "";
|
scheduleEl.innerHTML = "";
|
||||||
errorEl.classList.add("hidden");
|
errorEl.classList.add("hidden");
|
||||||
loadingEl.classList.remove("hidden");
|
loadingEl.classList.remove("hidden");
|
||||||
try {
|
try {
|
||||||
const status = await loadStatus();
|
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"), {
|
scheduleEl.appendChild(Object.assign(document.createElement("div"), {
|
||||||
className: "schedule-title",
|
className: "schedule-title",
|
||||||
textContent: "Занятость аудиторий",
|
textContent: "Занятость аудиторий",
|
||||||
@@ -386,14 +410,13 @@ async function renderRoomsView(day) {
|
|||||||
const wrap = document.createElement("div");
|
const wrap = document.createElement("div");
|
||||||
wrap.className = "rooms-wrap";
|
wrap.className = "rooms-wrap";
|
||||||
scheduleEl.appendChild(wrap);
|
scheduleEl.appendChild(wrap);
|
||||||
|
|
||||||
const data = await fetchJSON(`/api/rooms-day?day=${enc(chosenDay)}`);
|
|
||||||
wrap.appendChild(roomsDayTable(data));
|
wrap.appendChild(roomsDayTable(data));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (seq !== requestSeq) return;
|
||||||
errorEl.textContent = "Ошибка: " + err.message;
|
errorEl.textContent = "Ошибка: " + err.message;
|
||||||
errorEl.classList.remove("hidden");
|
errorEl.classList.remove("hidden");
|
||||||
} finally {
|
} finally {
|
||||||
loadingEl.classList.add("hidden");
|
if (seq === requestSeq) loadingEl.classList.add("hidden");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -323,6 +323,18 @@ main {
|
|||||||
color: var(--text);
|
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; }
|
.current-week .badge { margin-right: 0; font-size: 12px; }
|
||||||
|
|
||||||
.day-block {
|
.day-block {
|
||||||
|
|||||||
Reference in New Issue
Block a user