Compare commits
4 Commits
feat/foote
...
fix/temp-r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c04dc35075 | ||
| a41408559e | |||
|
|
8eaf006906 | ||
| 9f63b190f1 |
@@ -35,5 +35,4 @@ CORS_ORIGIN=http://localhost:5173
|
||||
# APP_VERSION=1.0.0
|
||||
|
||||
# ─── Frontend (Vite, локально из каталога frontend/) ─────────
|
||||
# В Docker-образе фронта базовый URL API задаётся при сборке (/api), не из .env.
|
||||
VITE_API_BASE_URL=http://localhost:3001
|
||||
# Браузер всегда ходит на относительный /api; в dev это проксирует Vite.
|
||||
|
||||
@@ -5,8 +5,6 @@ WORKDIR /app
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY frontend/ ./
|
||||
ARG VITE_API_BASE_URL=/api
|
||||
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
|
||||
15
README.md
15
README.md
@@ -4,20 +4,20 @@
|
||||
|
||||
## Переменные окружения
|
||||
|
||||
Один шаблон для локальной разработки и для Docker-стека: **[`.env.example`](.env.example)** → скопируйте в **`.env`** в корне репозитория.
|
||||
Один шаблон для локальной разработки и для Docker-стека: `**[.env.example](.env.example)`** → скопируйте в `**.env**` в корне репозитория.
|
||||
|
||||
Там же перечислены **`DB_HOST`**, **`DB_PORT`**, **`DB_NAME`**, **`DB_USER`**, **`DB_PASSWORD`** (подключение бэкенда к БД), **`PORT`** / **`API_PORT`**, опционально **`CALENDAR_RUN_MOCK_DB`**, **`CORS_ORIGIN`**, а для локального Vite — **`VITE_API_BASE_URL`**.
|
||||
Там же перечислены `**DB_HOST**`, `**DB_PORT**`, `**DB_NAME**`, `**DB_USER**`, `**DB_PASSWORD**` (подключение бэкенда к БД), `**PORT**` / `**API_PORT**`, опционально `**CALENDAR_RUN_MOCK_DB**` и `**CORS_ORIGIN**`.
|
||||
|
||||
## Backend — локально
|
||||
|
||||
1. `cd backend && npm install`
|
||||
2. Корень: `cp .env.example .env`, задайте `DB_*` (и при необходимости `CORS_ORIGIN`).
|
||||
3. Postgres: из корня `docker compose up -d` (см. [`docker-compose.yml`](docker-compose.yml)) — в compose используются те же `DB_NAME`, `DB_USER`, `DB_PASSWORD` из `.env`.
|
||||
3. Postgres: из корня `docker compose up -d` (см. `[docker-compose.yml](docker-compose.yml)`) — в compose используются те же `DB_NAME`, `DB_USER`, `DB_PASSWORD` из `.env`.
|
||||
4. `cd backend && npm run db:migrate && npm run seed`
|
||||
5. Dev-режим: `npm run dev`
|
||||
6. Или production: `npm run build && npm start`
|
||||
|
||||
Без PostgreSQL (только smoke API): в `.env` задайте `CALENDAR_RUN_MOCK_DB=1`; **`db:migrate` и `seed` с mock не использовать**.
|
||||
Без PostgreSQL (только smoke API): в `.env` задайте `CALENDAR_RUN_MOCK_DB=1`; `**db:migrate` и `seed` с mock не использовать**.
|
||||
|
||||
## Frontend — локально
|
||||
|
||||
@@ -28,11 +28,11 @@ npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Значение `VITE_API_BASE_URL` см. в **корневом** [`.env.example`](.env.example); для dev по умолчанию `http://localhost:3001`. У бэкенда `CORS_ORIGIN` должен совпадать с origin приложения (например `http://localhost:5173`).
|
||||
Фронт всегда отправляет запросы на относительный префикс `**/api**`. В dev это проксирует Vite на `http://localhost:3001`, в Docker/проде — nginx фронта проксирует на backend. У бэкенда `CORS_ORIGIN` должен совпадать с origin приложения (например `http://localhost:5173`).
|
||||
|
||||
## Docker: backend + frontend рядом с Postgres
|
||||
|
||||
Используйте [`docker-compose.stack.yml`](docker-compose.stack.yml): общая **внешняя** сеть с контейнером Postgres (как в вашей инфраструктуре). В корне должен быть **`.env`** (из `.env.example`): `DB_HOST` — имя сервиса/контейнера Postgres в этой сети, `DB_PORT=5432`, плюс остальные `DB_*` и **`CORS_ORIGIN=http://localhost:3033`**, если заходите на фронт с хоста на порту 3033.
|
||||
Используйте `[docker-compose.stack.yml](docker-compose.stack.yml)`: общая **внешняя** сеть с контейнером Postgres (как в вашей инфраструктуре). В корне должен быть `**.env`** (из `.env.example`): `DB_HOST` — имя сервиса/контейнера Postgres в этой сети, `DB_PORT=5432`, плюс остальные `DB_*` и `**CORS_ORIGIN=http://localhost:3033**`, если заходите на фронт с хоста на порту 3033.
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.stack.yml up -d --build
|
||||
@@ -40,9 +40,10 @@ docker compose -f docker-compose.stack.yml exec backend node dist/migrate.js
|
||||
docker compose -f docker-compose.stack.yml exec backend node dist/seed.js
|
||||
```
|
||||
|
||||
Фронт в браузере обращается к API по префиксу **`/api`** (nginx в образе фронта проксирует на backend).
|
||||
Фронт в браузере обращается к API по префиксу `**/api**` (nginx в образе фронта проксирует на backend).
|
||||
|
||||
## Документация API и бэкенда
|
||||
|
||||
- [Шпаргалка API для фронта](docs/backend-api-for-frontend.md)
|
||||
- [Эксплуатация backend](docs/backend.md)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "calendar-run-backend",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
|
||||
@@ -7,14 +7,30 @@ import racesRouter from "./routes/races";
|
||||
export function createApp(): express.Express {
|
||||
const app = express();
|
||||
|
||||
// TEMP: compare Host vs Origin for 200 vs 401 debugging — remove when done
|
||||
app.use((req, res, next) => {
|
||||
const host = req.headers.host;
|
||||
const origin = req.headers.origin;
|
||||
res.on("finish", () => {
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
tag: "temp-req-headers",
|
||||
host,
|
||||
origin: origin ?? null,
|
||||
method: req.method,
|
||||
path: req.originalUrl ?? req.url,
|
||||
status: res.statusCode,
|
||||
}),
|
||||
);
|
||||
});
|
||||
next();
|
||||
});
|
||||
|
||||
app.use(
|
||||
cors({ origin: config.corsOrigin, methods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"] }),
|
||||
);
|
||||
app.use(express.json());
|
||||
|
||||
app.use(healthRouter);
|
||||
app.use(racesRouter);
|
||||
// Тот же API под /api/* — если прокси не снимает префикс или запрос идёт напрямую на порт бэкенда с /api.
|
||||
app.use("/api", healthRouter);
|
||||
app.use("/api", racesRouter);
|
||||
|
||||
|
||||
@@ -5,59 +5,43 @@ import { createApp } from "../src/app";
|
||||
|
||||
const app = createApp();
|
||||
|
||||
test("GET /health returns ok", async () => {
|
||||
const res = await request(app).get("/health").expect(200);
|
||||
assert.equal(res.body.status, "ok");
|
||||
assert.equal(typeof res.body.version, "string");
|
||||
assert.ok(res.body.version.length > 0);
|
||||
});
|
||||
|
||||
test("GET /api/health returns ok (prefix without proxy strip)", async () => {
|
||||
test("GET /api/health returns ok", async () => {
|
||||
const res = await request(app).get("/api/health").expect(200);
|
||||
assert.equal(res.body.status, "ok");
|
||||
});
|
||||
|
||||
test("GET /meta returns version for UI footer", async () => {
|
||||
const res = await request(app).get("/meta").expect(200);
|
||||
assert.equal(typeof res.body.version, "string");
|
||||
assert.ok(res.body.version.length > 0);
|
||||
});
|
||||
|
||||
test("GET /api/meta mirrors GET /meta", async () => {
|
||||
test("GET /api/meta returns version for UI footer", async () => {
|
||||
const res = await request(app).get("/api/meta").expect(200);
|
||||
assert.equal(typeof res.body.version, "string");
|
||||
assert.ok(res.body.version.length > 0);
|
||||
});
|
||||
|
||||
test("GET /ready succeeds with mock database", async () => {
|
||||
const res = await request(app).get("/ready").expect(200);
|
||||
test("GET /api/ready succeeds with mock database", async () => {
|
||||
const res = await request(app).get("/api/ready").expect(200);
|
||||
assert.equal(res.body.status, "ready");
|
||||
assert.equal(res.body.db, "connected");
|
||||
});
|
||||
|
||||
test("GET /races rejects invalid year", async () => {
|
||||
const res = await request(app).get("/races?year=bad").expect(400);
|
||||
test("GET /api/races rejects invalid year", async () => {
|
||||
const res = await request(app).get("/api/races?year=bad").expect(400);
|
||||
assert.equal(res.body.error, "validation_error");
|
||||
assert.ok(Array.isArray(res.body.details));
|
||||
});
|
||||
|
||||
test("GET /races rejects month out of range", async () => {
|
||||
const res = await request(app).get("/races?month=13").expect(400);
|
||||
test("GET /api/races rejects month out of range", async () => {
|
||||
const res = await request(app).get("/api/races?month=13").expect(400);
|
||||
assert.equal(res.body.error, "validation_error");
|
||||
});
|
||||
|
||||
test("GET /races accepts year and month", async () => {
|
||||
const res = await request(app).get("/races?year=2026&month=5").expect(200);
|
||||
assert.ok(Array.isArray(res.body));
|
||||
});
|
||||
|
||||
test("GET /api/races mirrors GET /races", async () => {
|
||||
test("GET /api/races accepts year and month", async () => {
|
||||
const res = await request(app).get("/api/races?year=2026&month=5").expect(200);
|
||||
assert.ok(Array.isArray(res.body));
|
||||
});
|
||||
|
||||
test("GET /races/:id returns not_found", async () => {
|
||||
const res = await request(app).get("/races/does-not-exist").expect(404);
|
||||
test("GET /api/races/:id returns not_found", async () => {
|
||||
const res = await request(app).get("/api/races/does-not-exist").expect(404);
|
||||
assert.equal(res.body.error, "not_found");
|
||||
assert.ok(Array.isArray(res.body.details));
|
||||
});
|
||||
|
||||
@@ -38,8 +38,6 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.frontend
|
||||
args:
|
||||
VITE_API_BASE_URL: /api
|
||||
container_name: runners-calendar-frontend
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||
@@ -8,9 +8,8 @@ server {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Браузер ходит на тот же origin: /api/* → бэкенд без префикса /api
|
||||
# Браузер ходит на тот же origin: /api/* → бэкенд с тем же префиксом /api
|
||||
location /api/ {
|
||||
rewrite ^/api/(.*) /$1 break;
|
||||
proxy_pass http://backend:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
|
||||
@@ -2,13 +2,10 @@
|
||||
|
||||
## 1. Base URL
|
||||
|
||||
```
|
||||
VITE_API_BASE_URL=http://localhost:3001
|
||||
```
|
||||
SPA всегда отправляет запросы на относительный префикс `/api` текущего origin.
|
||||
|
||||
В коде SPA: `import.meta.env.VITE_API_BASE_URL`.
|
||||
|
||||
В Docker-стеке из репозитория образ фронта собирается с **`VITE_API_BASE_URL=/api`**: запросы идут на тот же origin, nginx проксирует `/api` на backend (см. `docker/nginx.frontend.conf`).
|
||||
- В dev (`npm run dev`): Vite proxy отправляет `/api/*` на `http://localhost:3001/api/*`.
|
||||
- В Docker/проде: nginx фронта проксирует `/api/*` на backend в той же сети.
|
||||
|
||||
## 2. CORS
|
||||
|
||||
@@ -22,7 +19,7 @@ CORS_ORIGIN=http://localhost:5173
|
||||
|
||||
## 3. Эндпоинты
|
||||
|
||||
### `GET /health`
|
||||
### `GET /api/health`
|
||||
|
||||
Liveness-проверка (без обращения к БД).
|
||||
|
||||
@@ -34,7 +31,7 @@ Liveness-проверка (без обращения к БД).
|
||||
|
||||
---
|
||||
|
||||
### `GET /ready`
|
||||
### `GET /api/ready`
|
||||
|
||||
Readiness-проверка (проверяет подключение к БД).
|
||||
|
||||
@@ -52,7 +49,7 @@ Readiness-проверка (проверяет подключение к БД).
|
||||
|
||||
---
|
||||
|
||||
### `GET /races`
|
||||
### `GET /api/races`
|
||||
|
||||
Список забегов, отсортированных по дате.
|
||||
|
||||
@@ -70,7 +67,7 @@ Readiness-проверка (проверяет подключение к БД).
|
||||
**Пример запроса:**
|
||||
|
||||
```
|
||||
GET /races?year=2026&month=5
|
||||
GET /api/races?year=2026&month=5
|
||||
```
|
||||
|
||||
**Ответ 200:**
|
||||
@@ -99,7 +96,7 @@ GET /races?year=2026&month=5
|
||||
|
||||
---
|
||||
|
||||
### `GET /races/:id`
|
||||
### `GET /api/races/:id`
|
||||
|
||||
Одна запись по `id`.
|
||||
|
||||
@@ -113,7 +110,7 @@ GET /races?year=2026&month=5
|
||||
|
||||
---
|
||||
|
||||
### `POST /races`
|
||||
### `POST /api/races`
|
||||
|
||||
Создание забега.
|
||||
|
||||
@@ -155,7 +152,7 @@ GET /races?year=2026&month=5
|
||||
|
||||
---
|
||||
|
||||
### `PATCH /races/:id`
|
||||
### `PATCH /api/races/:id`
|
||||
|
||||
Частичное обновление — передавать **только** изменяемые поля.
|
||||
|
||||
@@ -188,7 +185,7 @@ GET /races?year=2026&month=5
|
||||
|
||||
---
|
||||
|
||||
### `DELETE /races/:id`
|
||||
### `DELETE /api/races/:id`
|
||||
|
||||
Удаление забега.
|
||||
|
||||
@@ -220,7 +217,7 @@ GET /races?year=2026&month=5
|
||||
| `createdAt` | string | — | — | ISO timestamp (read-only) |
|
||||
| `updatedAt` | string \| null | — | — | ISO timestamp (read-only) |
|
||||
|
||||
## 5. Фильтрация списка (`GET /races`)
|
||||
## 5. Фильтрация списка (`GET /api/races`)
|
||||
|
||||
- **`year`** — целое число, фильтрует по `EXTRACT(YEAR FROM race_date)`.
|
||||
- **`month`** — целое число 1–12, фильтрует по `EXTRACT(MONTH FROM race_date)`.
|
||||
@@ -234,7 +231,7 @@ Seed-скрипт (`npm run seed` в `backend/`) выполняет **upsert**
|
||||
|
||||
## 7. Поведение при недоступной БД
|
||||
|
||||
- `GET /health` — всегда `200` (не проверяет БД).
|
||||
- `GET /ready` — при недоступной БД: `503 { "error": "database_unavailable", "db": "disconnected" }`. В режиме **`CALENDAR_RUN_MOCK_DB`** (dev/CI без Postgres) readiness возвращает успех без реального подключения — см. `docs/backend.md`.
|
||||
- `GET /api/health` — всегда `200` (не проверяет БД).
|
||||
- `GET /api/ready` — при недоступной БД: `503 { "error": "database_unavailable", "db": "disconnected" }`. В режиме **`CALENDAR_RUN_MOCK_DB`** (dev/CI без Postgres) readiness возвращает успех без реального подключения — см. `docs/backend.md`.
|
||||
- Все остальные маршруты — `503 { "error": "database_unavailable" }`.
|
||||
- В логах сервера: строка ошибки с контекстом маршрута.
|
||||
|
||||
@@ -78,7 +78,7 @@ API слушает порт: **`PORT`** (если задан), иначе **`API
|
||||
| `API_PORT` | Порт API-сервера | `3001` |
|
||||
| `CORS_ORIGIN` | Разрешённый origin для CORS | `http://localhost:5173` |
|
||||
|
||||
Для локального Vite в корневом `.env.example` также указан **`VITE_API_BASE_URL`** (читает только фронт из `frontend/`). В Docker-стеке базовый URL API задаётся при **сборке** образа фронта (`/api`), не через этот файл.
|
||||
Фронтенд всегда обращается к API по префиксу `/api` на текущем origin. В локальной разработке этот префикс проксирует Vite (`frontend/vite.config.ts`) на `http://localhost:3001`; в Docker-стеке — nginx фронта проксирует на backend.
|
||||
|
||||
**Без mock:** при отсутствии любой из `DB_*` процесс падает при старте: `Missing required environment variable: <NAME>`.
|
||||
|
||||
@@ -87,8 +87,8 @@ API слушает порт: **`PORT`** (если задан), иначе **`API
|
||||
## Поведение при недоступной БД
|
||||
|
||||
- **Старт сервера** — проходит успешно (env валидирован, Express слушает порт).
|
||||
- **`GET /health`** — всегда `200 { "status": "ok" }` (liveness, без обращения к БД).
|
||||
- **`GET /ready`** — при обычном режиме пробует подключиться к БД; `200` если ОК, `503 { "error": "database_unavailable", ... }` если нет. В режиме **`CALENDAR_RUN_MOCK_DB`** readiness считается успешным без реального подключения (удобно для CI/smoke API).
|
||||
- **`GET /api/health`** — всегда `200 { "status": "ok" }` (liveness, без обращения к БД).
|
||||
- **`GET /api/ready`** — при обычном режиме пробует подключиться к БД; `200` если ОК, `503 { "error": "database_unavailable", ... }` если нет. В режиме **`CALENDAR_RUN_MOCK_DB`** readiness считается успешным без реального подключения (удобно для CI/smoke API).
|
||||
- **Все остальные маршруты** при ошибке БД возвращают `503 { "error": "database_unavailable" }`.
|
||||
|
||||
## Структура каталога
|
||||
@@ -108,8 +108,8 @@ backend/
|
||||
│ ├── mappers/
|
||||
│ │ └── race.ts # snake_case ↔ camelCase
|
||||
│ └── routes/
|
||||
│ ├── health.ts # /health, /ready
|
||||
│ └── races.ts # CRUD /races
|
||||
│ ├── health.ts # /api/health, /api/ready, /api/meta
|
||||
│ └── races.ts # CRUD /api/races
|
||||
├── package.json
|
||||
└── tsconfig.json
|
||||
```
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
# Для локального npm run dev. Полный список переменных — в корневом .env.example репозитория.
|
||||
VITE_API_BASE_URL=http://localhost:3001
|
||||
# Для локального npm run dev дополнительных VITE-переменных не требуется.
|
||||
# Полный список переменных окружения — в корневом .env.example репозитория.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "calendar-run-frontend",
|
||||
"private": true,
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -47,7 +47,7 @@ function isGatewayStatus(status: number): boolean {
|
||||
return status === 502 || status === 503 || status === 504;
|
||||
}
|
||||
|
||||
function hasStructuredApiError(payload: unknown): payload is ApiErrorPayload {
|
||||
export function isStructuredApiErrorPayload(payload: unknown): payload is ApiErrorPayload {
|
||||
if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
|
||||
return false;
|
||||
}
|
||||
@@ -55,7 +55,7 @@ function hasStructuredApiError(payload: unknown): payload is ApiErrorPayload {
|
||||
}
|
||||
|
||||
export function toApiError(status: number, payload: unknown): ApiError {
|
||||
if (isGatewayStatus(status) && !hasStructuredApiError(payload)) {
|
||||
if (isGatewayStatus(status) && !isStructuredApiErrorPayload(payload)) {
|
||||
return new ApiError({
|
||||
code: "network_error",
|
||||
status,
|
||||
@@ -63,7 +63,7 @@ export function toApiError(status: number, payload: unknown): ApiError {
|
||||
});
|
||||
}
|
||||
|
||||
if (!hasStructuredApiError(payload) && (status === 401 || status === 403 || status === 404)) {
|
||||
if (!isStructuredApiErrorPayload(payload) && (status === 401 || status === 403 || status === 404)) {
|
||||
return new ApiError({
|
||||
code: "network_error",
|
||||
status,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { ApiError, toApiError } from "./errors";
|
||||
import { ApiError, isStructuredApiErrorPayload, toApiError } from "./errors";
|
||||
|
||||
const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL as string | undefined)?.trim() || "http://localhost:3001";
|
||||
const API_ROOT = "/api";
|
||||
|
||||
function buildUrl(path: string): string {
|
||||
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
||||
return `${API_BASE_URL}${normalizedPath}`;
|
||||
return `${API_ROOT}${normalizedPath}`;
|
||||
}
|
||||
|
||||
async function parseResponseBody(response: Response): Promise<unknown> {
|
||||
@@ -36,6 +36,14 @@ async function parseResponseBody(response: Response): Promise<unknown> {
|
||||
|
||||
const GATEWAY_RETRY_STATUSES = new Set([502, 503, 504]);
|
||||
|
||||
/** Повтор при «пустом» 404: часто бывает при нескольких инстансах/прокси до полного деплоя. */
|
||||
function shouldRetryIdempotentError(status: number, payload: unknown): boolean {
|
||||
if (GATEWAY_RETRY_STATUSES.has(status)) {
|
||||
return true;
|
||||
}
|
||||
return status === 404 && !isStructuredApiErrorPayload(payload);
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
@@ -69,7 +77,7 @@ export async function requestJson<T>(path: string, init?: RequestInit): Promise<
|
||||
const payload = await parseResponseBody(response);
|
||||
|
||||
if (!response.ok) {
|
||||
const retryable = idempotent && GATEWAY_RETRY_STATUSES.has(response.status) && attempt < maxAttempts;
|
||||
const retryable = idempotent && attempt < maxAttempts && shouldRetryIdempotentError(response.status, payload);
|
||||
if (retryable) {
|
||||
await delay(80 * attempt);
|
||||
continue;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { getBackendMeta } from "../../api";
|
||||
import { FRONTEND_VERSION } from "../../frontendVersion";
|
||||
import { readCachedBackendVersion, writeCachedBackendVersion } from "../../lib/backendVersionCache";
|
||||
|
||||
function isAbortError(error: unknown): boolean {
|
||||
return (
|
||||
@@ -10,7 +11,7 @@ function isAbortError(error: unknown): boolean {
|
||||
}
|
||||
|
||||
export function AppShellFooter(): JSX.Element {
|
||||
const [backendVersion, setBackendVersion] = useState<string | null>(null);
|
||||
const [backendVersion, setBackendVersion] = useState<string | null>(() => readCachedBackendVersion());
|
||||
|
||||
useEffect(() => {
|
||||
const ac = new AbortController();
|
||||
@@ -20,13 +21,24 @@ export function AppShellFooter(): JSX.Element {
|
||||
return;
|
||||
}
|
||||
const v = meta.version;
|
||||
setBackendVersion(typeof v === "string" && v.length > 0 ? v : "не указана");
|
||||
const label = typeof v === "string" && v.length > 0 ? v : "не указана";
|
||||
writeCachedBackendVersion(label);
|
||||
setBackendVersion(label);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (ac.signal.aborted || isAbortError(err)) {
|
||||
return;
|
||||
}
|
||||
setBackendVersion("недоступна");
|
||||
setBackendVersion((prev) => {
|
||||
const cached = readCachedBackendVersion();
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
if (prev !== null) {
|
||||
return prev;
|
||||
}
|
||||
return "недоступна";
|
||||
});
|
||||
});
|
||||
return () => ac.abort();
|
||||
}, []);
|
||||
|
||||
21
frontend/src/lib/backendVersionCache.ts
Normal file
21
frontend/src/lib/backendVersionCache.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
const STORAGE_KEY = "calendar_run.backendVersion.v1";
|
||||
|
||||
export function readCachedBackendVersion(): string | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(STORAGE_KEY);
|
||||
return raw !== null && raw.trim().length > 0 ? raw.trim() : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeCachedBackendVersion(version: string): void {
|
||||
try {
|
||||
if (version === "недоступна" || version === "не указана") {
|
||||
return;
|
||||
}
|
||||
sessionStorage.setItem(STORAGE_KEY, version);
|
||||
} catch {
|
||||
// private mode / quota
|
||||
}
|
||||
}
|
||||
@@ -3,4 +3,12 @@ import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://localhost:3001",
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user