Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
009011a917 | ||
|
|
399871f4f9 | ||
|
|
158fc36294 |
@@ -0,0 +1,16 @@
|
||||
# CodeGraph data files
|
||||
# These are local to each machine and should not be committed
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
|
||||
# Cache
|
||||
cache/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Hook markers
|
||||
.dirty
|
||||
@@ -5,15 +5,40 @@ alwaysApply: true
|
||||
|
||||
# Локальный запуск проекта
|
||||
|
||||
Для запуска всего проекта в dev-режиме поднимать два процесса:
|
||||
## Требования
|
||||
|
||||
- **Node.js 22**, npm с workspaces.
|
||||
- Первый раз (или после смены зависимостей): `npm install` из корня репозитория.
|
||||
- Backend: скопировать `backend/.env.example` → `backend/.env` (по умолчанию `PORT=8000`, `CORS_ORIGIN=http://localhost:3000`).
|
||||
|
||||
## Запуск (два процесса)
|
||||
|
||||
Из корня репозитория поднять **два** long-running процесса в **отдельных** терминалах:
|
||||
|
||||
```powershell
|
||||
npm run dev
|
||||
npm --prefix backend run dev
|
||||
```
|
||||
|
||||
- Frontend: `http://localhost:3000`
|
||||
- Backend: `http://localhost:8000`
|
||||
- Health check backend: `http://localhost:8000/health`
|
||||
| Сервис | URL | Проверка |
|
||||
|--------|-----|----------|
|
||||
| Frontend (Next.js 16, Turbopack) | http://localhost:3000 | открыть в браузере |
|
||||
| Backend (Fastify) | http://localhost:8000 | `GET /health` |
|
||||
|
||||
Если пользователь просит “запусти проект”, “запусти фронт и бэк” или похожую команду, сначала проверь уже запущенные терминалы, затем запускай эти две команды отдельными long-running процессами.
|
||||
Проверка backend в PowerShell:
|
||||
|
||||
```powershell
|
||||
Invoke-WebRequest -Uri http://localhost:8000/health -UseBasicParsing | Select-Object -ExpandProperty Content
|
||||
```
|
||||
|
||||
Ожидаемый ответ: `{"status":"ok",...}`.
|
||||
|
||||
## Поведение агента
|
||||
|
||||
Если пользователь просит «запусти проект», «запусти фронт и бэк» или похожее:
|
||||
|
||||
1. Сначала проверить уже запущенные терминалы — не дублировать процессы.
|
||||
2. Запустить обе команды выше как фоновые long-running процессы.
|
||||
3. Дождаться готовности: frontend — `Ready`, backend — `Server listening` / успешный `/health`.
|
||||
|
||||
Подробности архитектуры и env — `README.md`, раздел «Запуск».
|
||||
|
||||
@@ -10,3 +10,7 @@
|
||||
4. Локальный hook `.githooks/commit-msg` отклоняет subject без кириллицы; подключение — `npm install` / `npm run prepare`.
|
||||
|
||||
Полные правила: `.cursor/rules/commit-messages-ru.mdc`, semver — `.cursor/rules/release-versioning.mdc`.
|
||||
|
||||
## Локальный запуск
|
||||
|
||||
Два процесса из корня: `npm run dev` (frontend :3000) и `npm --prefix backend run dev` (backend :8000). Перед первым запуском — `npm install`, для backend — `backend/.env` из `backend/.env.example`. Подробности — `.cursor/rules/dev-run-command.mdc` и `README.md`.
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { NextRequest } from "next/server"
|
||||
import { proxyBackendRequest } from "@/lib/proxy-backend-request"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
export const dynamic = "force-dynamic"
|
||||
export const maxDuration = 600
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return proxyBackendRequest(request, "/api/system/database/backup", {
|
||||
method: "GET",
|
||||
forwardRequestBody: false,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { NextRequest } from "next/server"
|
||||
import { proxyBackendRequest } from "@/lib/proxy-backend-request"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
export const dynamic = "force-dynamic"
|
||||
export const maxDuration = 600
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
return proxyBackendRequest(request, "/api/system/database/restore", { method: "POST" })
|
||||
}
|
||||
@@ -27,6 +27,8 @@ import { refreshScheduler, stopScheduler } from "./services/scheduler.js"
|
||||
// ── app factory ────────────────────────────────────────────────────────────────
|
||||
|
||||
const app = Fastify({
|
||||
bodyLimit: 512 * 1024 * 1024,
|
||||
requestTimeout: 10 * 60 * 1000,
|
||||
logger: {
|
||||
transport: {
|
||||
target: "pino-pretty",
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export function backendInternalUrl(): string {
|
||||
return (process.env.BACKEND_INTERNAL_URL ?? "http://127.0.0.1:8000").replace(/\/$/, "")
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { NextRequest } from "next/server"
|
||||
import { backendInternalUrl } from "@/lib/backend-internal-url"
|
||||
|
||||
type ProxyBackendRequestOptions = {
|
||||
method?: string
|
||||
forwardRequestBody?: boolean
|
||||
}
|
||||
|
||||
export async function proxyBackendRequest(
|
||||
request: NextRequest,
|
||||
backendPath: string,
|
||||
options: ProxyBackendRequestOptions = {},
|
||||
): Promise<Response> {
|
||||
const method = options.method ?? request.method
|
||||
const headers = new Headers()
|
||||
for (const [key, value] of request.headers.entries()) {
|
||||
const lower = key.toLowerCase()
|
||||
if (lower === "host" || lower === "connection") continue
|
||||
headers.set(key, value)
|
||||
}
|
||||
|
||||
const fetchInit: RequestInit & { duplex?: "half" } = {
|
||||
method,
|
||||
headers,
|
||||
cache: "no-store",
|
||||
}
|
||||
|
||||
const forwardRequestBody = options.forwardRequestBody ?? !["GET", "HEAD"].includes(method)
|
||||
if (forwardRequestBody && request.body) {
|
||||
fetchInit.body = request.body
|
||||
fetchInit.duplex = "half"
|
||||
}
|
||||
|
||||
try {
|
||||
const upstream = await fetch(`${backendInternalUrl()}${backendPath}`, fetchInit)
|
||||
const responseHeaders = new Headers()
|
||||
const contentType = upstream.headers.get("Content-Type")
|
||||
const contentDisposition = upstream.headers.get("Content-Disposition")
|
||||
if (contentType) responseHeaders.set("Content-Type", contentType)
|
||||
if (contentDisposition) responseHeaders.set("Content-Disposition", contentDisposition)
|
||||
|
||||
return new Response(upstream.body, {
|
||||
status: upstream.status,
|
||||
headers: responseHeaders,
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Не удалось связаться с бекендом"
|
||||
return Response.json({ error: message }, { status: 502 })
|
||||
}
|
||||
}
|
||||
+7
-7
@@ -1,16 +1,16 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const backendInternalUrl = (process.env.BACKEND_INTERNAL_URL ?? "http://127.0.0.1:8000").replace(
|
||||
/\/$/,
|
||||
"",
|
||||
);
|
||||
import { backendInternalUrl } from "./lib/backend-internal-url";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
experimental: {
|
||||
proxyClientMaxBodySize: "512mb",
|
||||
},
|
||||
async rewrites() {
|
||||
const backendUrl = backendInternalUrl();
|
||||
return [
|
||||
{ source: "/health", destination: `${backendInternalUrl}/health` },
|
||||
{ source: "/api/:path*", destination: `${backendInternalUrl}/api/:path*` },
|
||||
{ source: "/health", destination: `${backendUrl}/health` },
|
||||
{ source: "/api/:path*", destination: `${backendUrl}/api/:path*` },
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import { ApiClientError } from "@/shared/api/http-client"
|
||||
import { configuredBackendUrl } from "@/lib/backend-url"
|
||||
|
||||
const MAX_RESTORE_BYTES = 512 * 1024 * 1024
|
||||
|
||||
function trimBaseUrl(baseUrl: string): string {
|
||||
return baseUrl.replace(/\/$/, "")
|
||||
}
|
||||
|
||||
function resolveDatabaseApiUrl(baseUrl: string, path: string): string {
|
||||
if (configuredBackendUrl().kind === "same-origin") {
|
||||
return path
|
||||
}
|
||||
return `${trimBaseUrl(baseUrl)}${path}`
|
||||
}
|
||||
|
||||
function parseFilename(contentDisposition: string | null, fallback: string): string {
|
||||
if (!contentDisposition) return fallback
|
||||
const utfMatch = /filename\*=UTF-8''([^;]+)/i.exec(contentDisposition)
|
||||
@@ -22,7 +32,7 @@ function parseFilename(contentDisposition: string | null, fallback: string): str
|
||||
export async function downloadSystemDatabaseBackup(
|
||||
baseUrl: string,
|
||||
): Promise<{ blob: Blob; filename: string }> {
|
||||
const res = await fetch(`${trimBaseUrl(baseUrl)}/api/system/database/backup`)
|
||||
const res = await fetch(resolveDatabaseApiUrl(baseUrl, "/api/system/database/backup"))
|
||||
if (!res.ok) {
|
||||
const payload = await res.json().catch(() => undefined)
|
||||
const msg =
|
||||
@@ -40,11 +50,16 @@ export async function downloadSystemDatabaseBackup(
|
||||
}
|
||||
|
||||
export async function restoreSystemDatabaseBackup(baseUrl: string, file: File): Promise<void> {
|
||||
const body = await file.arrayBuffer()
|
||||
const res = await fetch(`${trimBaseUrl(baseUrl)}/api/system/database/restore`, {
|
||||
if (file.size > MAX_RESTORE_BYTES) {
|
||||
throw new ApiClientError(
|
||||
`Файл больше ${MAX_RESTORE_BYTES / (1024 * 1024)} МБ — уменьшите бэкап или обратитесь к администратору`,
|
||||
413,
|
||||
)
|
||||
}
|
||||
const res = await fetch(resolveDatabaseApiUrl(baseUrl, "/api/system/database/restore"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/octet-stream" },
|
||||
body,
|
||||
body: file,
|
||||
})
|
||||
if (!res.ok) {
|
||||
const payload = await res.json().catch(() => undefined)
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user