Compare commits
8 Commits
fix/prod-c
...
fix/temp-r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c04dc35075 | ||
| a41408559e | |||
|
|
8eaf006906 | ||
| 9f63b190f1 | |||
|
|
83bc603b95 | ||
| f8b4ce7111 | |||
|
|
53b9561a54 | ||
| 7e9c20d4bf |
@@ -35,5 +35,4 @@ CORS_ORIGIN=http://localhost:5173
|
|||||||
# APP_VERSION=1.0.0
|
# APP_VERSION=1.0.0
|
||||||
|
|
||||||
# ─── Frontend (Vite, локально из каталога frontend/) ─────────
|
# ─── Frontend (Vite, локально из каталога frontend/) ─────────
|
||||||
# В Docker-образе фронта базовый URL API задаётся при сборке (/api), не из .env.
|
# Браузер всегда ходит на относительный /api; в dev это проксирует Vite.
|
||||||
VITE_API_BASE_URL=http://localhost:3001
|
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ WORKDIR /app
|
|||||||
COPY frontend/package.json frontend/package-lock.json ./
|
COPY frontend/package.json frontend/package-lock.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
COPY frontend/ ./
|
COPY frontend/ ./
|
||||||
ARG VITE_API_BASE_URL=/api
|
|
||||||
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM nginx:alpine
|
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 — локально
|
## Backend — локально
|
||||||
|
|
||||||
1. `cd backend && npm install`
|
1. `cd backend && npm install`
|
||||||
2. Корень: `cp .env.example .env`, задайте `DB_*` (и при необходимости `CORS_ORIGIN`).
|
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`
|
4. `cd backend && npm run db:migrate && npm run seed`
|
||||||
5. Dev-режим: `npm run dev`
|
5. Dev-режим: `npm run dev`
|
||||||
6. Или production: `npm run build && npm start`
|
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 — локально
|
## Frontend — локально
|
||||||
|
|
||||||
@@ -28,11 +28,11 @@ npm install
|
|||||||
npm run dev
|
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: 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
|
```bash
|
||||||
docker compose -f docker-compose.stack.yml up -d --build
|
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
|
docker compose -f docker-compose.stack.yml exec backend node dist/seed.js
|
||||||
```
|
```
|
||||||
|
|
||||||
Фронт в браузере обращается к API по префиксу **`/api`** (nginx в образе фронта проксирует на backend).
|
Фронт в браузере обращается к API по префиксу `**/api**` (nginx в образе фронта проксирует на backend).
|
||||||
|
|
||||||
## Документация API и бэкенда
|
## Документация API и бэкенда
|
||||||
|
|
||||||
- [Шпаргалка API для фронта](docs/backend-api-for-frontend.md)
|
- [Шпаргалка API для фронта](docs/backend-api-for-frontend.md)
|
||||||
- [Эксплуатация backend](docs/backend.md)
|
- [Эксплуатация backend](docs/backend.md)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "calendar-run-backend",
|
"name": "calendar-run-backend",
|
||||||
"version": "1.0.0",
|
"version": "1.2.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
|
|||||||
@@ -7,11 +7,32 @@ import racesRouter from "./routes/races";
|
|||||||
export function createApp(): express.Express {
|
export function createApp(): express.Express {
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
app.use(cors({ origin: config.corsOrigin, methods: ["GET", "POST", "PATCH", "DELETE"] }));
|
// 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(express.json());
|
||||||
|
|
||||||
app.use(healthRouter);
|
app.use("/api", healthRouter);
|
||||||
app.use(racesRouter);
|
app.use("/api", racesRouter);
|
||||||
|
|
||||||
app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {
|
app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {
|
||||||
if (err instanceof SyntaxError && "body" in err) {
|
if (err instanceof SyntaxError && "body" in err) {
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ router.get("/health", (_req: Request, res: Response) => {
|
|||||||
res.json({ status: "ok", version: getBackendVersion() });
|
res.json({ status: "ok", version: getBackendVersion() });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Версия для UI; путь без «health», чтобы реже резался фильтрами/прокси. */
|
||||||
|
router.get("/meta", (_req: Request, res: Response) => {
|
||||||
|
res.json({ version: getBackendVersion() });
|
||||||
|
});
|
||||||
|
|
||||||
router.get("/ready", async (_req: Request, res: Response) => {
|
router.get("/ready", async (_req: Request, res: Response) => {
|
||||||
const dbOk = await checkDbConnection();
|
const dbOk = await checkDbConnection();
|
||||||
if (dbOk) {
|
if (dbOk) {
|
||||||
|
|||||||
@@ -5,37 +5,43 @@ import { createApp } from "../src/app";
|
|||||||
|
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
|
|
||||||
test("GET /health returns ok", async () => {
|
test("GET /api/health returns ok", async () => {
|
||||||
const res = await request(app).get("/health").expect(200);
|
const res = await request(app).get("/api/health").expect(200);
|
||||||
assert.equal(res.body.status, "ok");
|
assert.equal(res.body.status, "ok");
|
||||||
assert.equal(typeof res.body.version, "string");
|
assert.equal(typeof res.body.version, "string");
|
||||||
assert.ok(res.body.version.length > 0);
|
assert.ok(res.body.version.length > 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("GET /ready succeeds with mock database", async () => {
|
test("GET /api/meta returns version for UI footer", async () => {
|
||||||
const res = await request(app).get("/ready").expect(200);
|
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 /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.status, "ready");
|
||||||
assert.equal(res.body.db, "connected");
|
assert.equal(res.body.db, "connected");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("GET /races rejects invalid year", async () => {
|
test("GET /api/races rejects invalid year", async () => {
|
||||||
const res = await request(app).get("/races?year=bad").expect(400);
|
const res = await request(app).get("/api/races?year=bad").expect(400);
|
||||||
assert.equal(res.body.error, "validation_error");
|
assert.equal(res.body.error, "validation_error");
|
||||||
assert.ok(Array.isArray(res.body.details));
|
assert.ok(Array.isArray(res.body.details));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("GET /races rejects month out of range", async () => {
|
test("GET /api/races rejects month out of range", async () => {
|
||||||
const res = await request(app).get("/races?month=13").expect(400);
|
const res = await request(app).get("/api/races?month=13").expect(400);
|
||||||
assert.equal(res.body.error, "validation_error");
|
assert.equal(res.body.error, "validation_error");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("GET /races accepts year and month", async () => {
|
test("GET /api/races accepts year and month", async () => {
|
||||||
const res = await request(app).get("/races?year=2026&month=5").expect(200);
|
const res = await request(app).get("/api/races?year=2026&month=5").expect(200);
|
||||||
assert.ok(Array.isArray(res.body));
|
assert.ok(Array.isArray(res.body));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("GET /races/:id returns not_found", async () => {
|
test("GET /api/races/:id returns not_found", async () => {
|
||||||
const res = await request(app).get("/races/does-not-exist").expect(404);
|
const res = await request(app).get("/api/races/does-not-exist").expect(404);
|
||||||
assert.equal(res.body.error, "not_found");
|
assert.equal(res.body.error, "not_found");
|
||||||
assert.ok(Array.isArray(res.body.details));
|
assert.ok(Array.isArray(res.body.details));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -38,8 +38,6 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile.frontend
|
dockerfile: Dockerfile.frontend
|
||||||
args:
|
|
||||||
VITE_API_BASE_URL: /api
|
|
||||||
container_name: runners-calendar-frontend
|
container_name: runners-calendar-frontend
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
|
|||||||
@@ -8,9 +8,8 @@ server {
|
|||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Браузер ходит на тот же origin: /api/* → бэкенд без префикса /api
|
# Браузер ходит на тот же origin: /api/* → бэкенд с тем же префиксом /api
|
||||||
location /api/ {
|
location /api/ {
|
||||||
rewrite ^/api/(.*) /$1 break;
|
|
||||||
proxy_pass http://backend:3000;
|
proxy_pass http://backend:3000;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
|
|||||||
@@ -2,13 +2,10 @@
|
|||||||
|
|
||||||
## 1. Base URL
|
## 1. Base URL
|
||||||
|
|
||||||
```
|
SPA всегда отправляет запросы на относительный префикс `/api` текущего origin.
|
||||||
VITE_API_BASE_URL=http://localhost:3001
|
|
||||||
```
|
|
||||||
|
|
||||||
В коде SPA: `import.meta.env.VITE_API_BASE_URL`.
|
- В dev (`npm run dev`): Vite proxy отправляет `/api/*` на `http://localhost:3001/api/*`.
|
||||||
|
- В Docker/проде: nginx фронта проксирует `/api/*` на backend в той же сети.
|
||||||
В Docker-стеке из репозитория образ фронта собирается с **`VITE_API_BASE_URL=/api`**: запросы идут на тот же origin, nginx проксирует `/api` на backend (см. `docker/nginx.frontend.conf`).
|
|
||||||
|
|
||||||
## 2. CORS
|
## 2. CORS
|
||||||
|
|
||||||
@@ -22,7 +19,7 @@ CORS_ORIGIN=http://localhost:5173
|
|||||||
|
|
||||||
## 3. Эндпоинты
|
## 3. Эндпоинты
|
||||||
|
|
||||||
### `GET /health`
|
### `GET /api/health`
|
||||||
|
|
||||||
Liveness-проверка (без обращения к БД).
|
Liveness-проверка (без обращения к БД).
|
||||||
|
|
||||||
@@ -34,7 +31,7 @@ Liveness-проверка (без обращения к БД).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### `GET /ready`
|
### `GET /api/ready`
|
||||||
|
|
||||||
Readiness-проверка (проверяет подключение к БД).
|
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:**
|
**Ответ 200:**
|
||||||
@@ -99,7 +96,7 @@ GET /races?year=2026&month=5
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### `GET /races/:id`
|
### `GET /api/races/:id`
|
||||||
|
|
||||||
Одна запись по `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) |
|
| `createdAt` | string | — | — | ISO timestamp (read-only) |
|
||||||
| `updatedAt` | string \| null | — | — | ISO timestamp (read-only) |
|
| `updatedAt` | string \| null | — | — | ISO timestamp (read-only) |
|
||||||
|
|
||||||
## 5. Фильтрация списка (`GET /races`)
|
## 5. Фильтрация списка (`GET /api/races`)
|
||||||
|
|
||||||
- **`year`** — целое число, фильтрует по `EXTRACT(YEAR FROM race_date)`.
|
- **`year`** — целое число, фильтрует по `EXTRACT(YEAR FROM race_date)`.
|
||||||
- **`month`** — целое число 1–12, фильтрует по `EXTRACT(MONTH FROM race_date)`.
|
- **`month`** — целое число 1–12, фильтрует по `EXTRACT(MONTH FROM race_date)`.
|
||||||
@@ -234,7 +231,7 @@ Seed-скрипт (`npm run seed` в `backend/`) выполняет **upsert**
|
|||||||
|
|
||||||
## 7. Поведение при недоступной БД
|
## 7. Поведение при недоступной БД
|
||||||
|
|
||||||
- `GET /health` — всегда `200` (не проверяет БД).
|
- `GET /api/health` — всегда `200` (не проверяет БД).
|
||||||
- `GET /ready` — при недоступной БД: `503 { "error": "database_unavailable", "db": "disconnected" }`. В режиме **`CALENDAR_RUN_MOCK_DB`** (dev/CI без Postgres) readiness возвращает успех без реального подключения — см. `docs/backend.md`.
|
- `GET /api/ready` — при недоступной БД: `503 { "error": "database_unavailable", "db": "disconnected" }`. В режиме **`CALENDAR_RUN_MOCK_DB`** (dev/CI без Postgres) readiness возвращает успех без реального подключения — см. `docs/backend.md`.
|
||||||
- Все остальные маршруты — `503 { "error": "database_unavailable" }`.
|
- Все остальные маршруты — `503 { "error": "database_unavailable" }`.
|
||||||
- В логах сервера: строка ошибки с контекстом маршрута.
|
- В логах сервера: строка ошибки с контекстом маршрута.
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ API слушает порт: **`PORT`** (если задан), иначе **`API
|
|||||||
| `API_PORT` | Порт API-сервера | `3001` |
|
| `API_PORT` | Порт API-сервера | `3001` |
|
||||||
| `CORS_ORIGIN` | Разрешённый origin для CORS | `http://localhost:5173` |
|
| `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>`.
|
**Без mock:** при отсутствии любой из `DB_*` процесс падает при старте: `Missing required environment variable: <NAME>`.
|
||||||
|
|
||||||
@@ -87,8 +87,8 @@ API слушает порт: **`PORT`** (если задан), иначе **`API
|
|||||||
## Поведение при недоступной БД
|
## Поведение при недоступной БД
|
||||||
|
|
||||||
- **Старт сервера** — проходит успешно (env валидирован, Express слушает порт).
|
- **Старт сервера** — проходит успешно (env валидирован, Express слушает порт).
|
||||||
- **`GET /health`** — всегда `200 { "status": "ok" }` (liveness, без обращения к БД).
|
- **`GET /api/health`** — всегда `200 { "status": "ok" }` (liveness, без обращения к БД).
|
||||||
- **`GET /ready`** — при обычном режиме пробует подключиться к БД; `200` если ОК, `503 { "error": "database_unavailable", ... }` если нет. В режиме **`CALENDAR_RUN_MOCK_DB`** readiness считается успешным без реального подключения (удобно для CI/smoke API).
|
- **`GET /api/ready`** — при обычном режиме пробует подключиться к БД; `200` если ОК, `503 { "error": "database_unavailable", ... }` если нет. В режиме **`CALENDAR_RUN_MOCK_DB`** readiness считается успешным без реального подключения (удобно для CI/smoke API).
|
||||||
- **Все остальные маршруты** при ошибке БД возвращают `503 { "error": "database_unavailable" }`.
|
- **Все остальные маршруты** при ошибке БД возвращают `503 { "error": "database_unavailable" }`.
|
||||||
|
|
||||||
## Структура каталога
|
## Структура каталога
|
||||||
@@ -108,8 +108,8 @@ backend/
|
|||||||
│ ├── mappers/
|
│ ├── mappers/
|
||||||
│ │ └── race.ts # snake_case ↔ camelCase
|
│ │ └── race.ts # snake_case ↔ camelCase
|
||||||
│ └── routes/
|
│ └── routes/
|
||||||
│ ├── health.ts # /health, /ready
|
│ ├── health.ts # /api/health, /api/ready, /api/meta
|
||||||
│ └── races.ts # CRUD /races
|
│ └── races.ts # CRUD /api/races
|
||||||
├── package.json
|
├── package.json
|
||||||
└── tsconfig.json
|
└── tsconfig.json
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
# Для локального npm run dev. Полный список переменных — в корневом .env.example репозитория.
|
# Для локального npm run dev дополнительных VITE-переменных не требуется.
|
||||||
VITE_API_BASE_URL=http://localhost:3001
|
# Полный список переменных окружения — в корневом .env.example репозитория.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "calendar-run-frontend",
|
"name": "calendar-run-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.3.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ function isGatewayStatus(status: number): boolean {
|
|||||||
return status === 502 || status === 503 || status === 504;
|
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)) {
|
if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -55,7 +55,7 @@ function hasStructuredApiError(payload: unknown): payload is ApiErrorPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function toApiError(status: number, payload: unknown): ApiError {
|
export function toApiError(status: number, payload: unknown): ApiError {
|
||||||
if (isGatewayStatus(status) && !hasStructuredApiError(payload)) {
|
if (isGatewayStatus(status) && !isStructuredApiErrorPayload(payload)) {
|
||||||
return new ApiError({
|
return new ApiError({
|
||||||
code: "network_error",
|
code: "network_error",
|
||||||
status,
|
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({
|
return new ApiError({
|
||||||
code: "network_error",
|
code: "network_error",
|
||||||
status,
|
status,
|
||||||
|
|||||||
@@ -5,6 +5,15 @@ export type HealthResponse = {
|
|||||||
version: string;
|
version: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type BackendMetaResponse = {
|
||||||
|
version: string;
|
||||||
|
};
|
||||||
|
|
||||||
export async function getHealth(init?: RequestInit): Promise<HealthResponse> {
|
export async function getHealth(init?: RequestInit): Promise<HealthResponse> {
|
||||||
return requestJson<HealthResponse>("/health", init);
|
return requestJson<HealthResponse>("/health", init);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Версия бэкенда для футера (отдельный путь от /health — меньше ложных блокировок). */
|
||||||
|
export async function getBackendMeta(init?: RequestInit): Promise<BackendMetaResponse> {
|
||||||
|
return requestJson<BackendMetaResponse>("/meta", init);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,27 +1,49 @@
|
|||||||
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 {
|
function buildUrl(path: string): string {
|
||||||
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
||||||
return `${API_BASE_URL}${normalizedPath}`;
|
return `${API_ROOT}${normalizedPath}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function parseResponseBody(response: Response): Promise<unknown> {
|
async function parseResponseBody(response: Response): Promise<unknown> {
|
||||||
const contentType = response.headers.get("content-type") ?? "";
|
const text = await response.text();
|
||||||
if (!contentType.includes("application/json")) {
|
if (!text.trim()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const contentType = response.headers.get("content-type") ?? "";
|
||||||
|
if (contentType.includes("application/json")) {
|
||||||
try {
|
try {
|
||||||
return await response.json();
|
return JSON.parse(text) as unknown;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(text) as unknown;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const GATEWAY_RETRY_STATUSES = new Set([502, 503, 504]);
|
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> {
|
function delay(ms: number): Promise<void> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
setTimeout(resolve, ms);
|
setTimeout(resolve, ms);
|
||||||
@@ -35,10 +57,15 @@ export async function requestJson<T>(path: string, init?: RequestInit): Promise<
|
|||||||
|
|
||||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||||
try {
|
try {
|
||||||
|
const defaultHeaders: Record<string, string> = {};
|
||||||
|
if (method !== "GET" && method !== "HEAD") {
|
||||||
|
defaultHeaders["Content-Type"] = "application/json";
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(buildUrl(path), {
|
const response = await fetch(buildUrl(path), {
|
||||||
...init,
|
...init,
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
...defaultHeaders,
|
||||||
...(init?.headers ?? {}),
|
...(init?.headers ?? {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -50,30 +77,11 @@ export async function requestJson<T>(path: string, init?: RequestInit): Promise<
|
|||||||
const payload = await parseResponseBody(response);
|
const payload = await parseResponseBody(response);
|
||||||
|
|
||||||
if (!response.ok) {
|
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) {
|
if (retryable) {
|
||||||
await delay(80 * attempt);
|
await delay(80 * attempt);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// #region agent log
|
|
||||||
fetch("http://127.0.0.1:7488/ingest/a18f912f-72c6-4a58-866b-17810a6b89d2", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json", "X-Debug-Session-Id": "587ee5" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
sessionId: "587ee5",
|
|
||||||
hypothesisId: "H-http-not-ok",
|
|
||||||
location: "http.ts:requestJson",
|
|
||||||
message: "HTTP error response",
|
|
||||||
data: {
|
|
||||||
path,
|
|
||||||
status: response.status,
|
|
||||||
contentType: response.headers.get("content-type"),
|
|
||||||
payloadIsObject: payload !== null && typeof payload === "object",
|
|
||||||
},
|
|
||||||
timestamp: Date.now(),
|
|
||||||
}),
|
|
||||||
}).catch(() => {});
|
|
||||||
// #endregion
|
|
||||||
throw toApiError(response.status, payload);
|
throw toApiError(response.status, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export type { CreateRacePayload, Race, RacesQuery, RaceStatus, UpdateRacePayload } from "./types";
|
export type { CreateRacePayload, Race, RacesQuery, RaceStatus, UpdateRacePayload } from "./types";
|
||||||
export { ApiError, getApiErrorMessage } from "./errors";
|
export { ApiError, getApiErrorMessage } from "./errors";
|
||||||
export type { HealthResponse } from "./health";
|
export type { BackendMetaResponse, HealthResponse } from "./health";
|
||||||
export { getHealth } from "./health";
|
export { getBackendMeta, getHealth } from "./health";
|
||||||
export { getRaceById, getRaces, createRace, updateRace, deleteRace } from "./races";
|
export { getRaceById, getRaces, createRace, updateRace, deleteRace } from "./races";
|
||||||
|
|||||||
@@ -1,25 +1,44 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { getHealth } from "../../api";
|
import { getBackendMeta } from "../../api";
|
||||||
import { FRONTEND_VERSION } from "../../frontendVersion";
|
import { FRONTEND_VERSION } from "../../frontendVersion";
|
||||||
|
import { readCachedBackendVersion, writeCachedBackendVersion } from "../../lib/backendVersionCache";
|
||||||
|
|
||||||
|
function isAbortError(error: unknown): boolean {
|
||||||
|
return (
|
||||||
|
(error instanceof DOMException && error.name === "AbortError") ||
|
||||||
|
(error instanceof Error && error.name === "AbortError")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function AppShellFooter(): JSX.Element {
|
export function AppShellFooter(): JSX.Element {
|
||||||
const [backendVersion, setBackendVersion] = useState<string | null>(null);
|
const [backendVersion, setBackendVersion] = useState<string | null>(() => readCachedBackendVersion());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const ac = new AbortController();
|
const ac = new AbortController();
|
||||||
void getHealth({ signal: ac.signal })
|
void getBackendMeta({ signal: ac.signal })
|
||||||
.then((h) => {
|
.then((meta) => {
|
||||||
if (ac.signal.aborted) {
|
if (ac.signal.aborted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const v = h.version;
|
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(() => {
|
.catch((err) => {
|
||||||
if (ac.signal.aborted) {
|
if (ac.signal.aborted || isAbortError(err)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setBackendVersion("недоступна");
|
setBackendVersion((prev) => {
|
||||||
|
const cached = readCachedBackendVersion();
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
if (prev !== null) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
return "недоступна";
|
||||||
|
});
|
||||||
});
|
});
|
||||||
return () => ac.abort();
|
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({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
"/api": {
|
||||||
|
target: "http://localhost:3001",
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user