Compare commits

...
6 Commits
Author SHA1 Message Date
Denozordec 009011a917 chore(docs): обновить инструкции по локальному запуску проекта
Docker images / prepare-release (push) Successful in 7s
Docker images / backend-image (push) Successful in 3m42s
Docker images / frontend-image (push) Successful in 2m40s
Docker images / updater-image (push) Successful in 53s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 8s
2026-06-30 20:07:20 +07:00
Denozordec 399871f4f9 fix(config): улучшить обработку URL для бэкенда и увеличить таймаут запросов
Docker images / prepare-release (push) Successful in 6s
Docker images / backend-image (push) Successful in 1m36s
Docker images / frontend-image (push) Successful in 1m39s
Docker images / updater-image (push) Successful in 50s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 8s
2026-05-12 23:19:51 +07:00
Denozordec 158fc36294 fix(api): увеличить лимит размера тела запроса и улучшить обработку URL для API
Docker images / prepare-release (push) Successful in 6s
Docker images / backend-image (push) Successful in 1m36s
Docker images / frontend-image (push) Successful in 1m40s
Docker images / updater-image (push) Successful in 38s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 6s
2026-05-12 23:11:23 +07:00
Denozordec 7dc4836c71 fix(dashboard): улучшить логику загрузки данных на странице панели управления
Docker images / prepare-release (push) Successful in 6s
Docker images / backend-image (push) Successful in 1m43s
Docker images / frontend-image (push) Successful in 1m47s
Docker images / updater-image (push) Successful in 54s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 7s
2026-05-12 22:47:30 +07:00
Denozordec efc3812e12 fix(updater): обновить URL для проверки состояния сервиса 2026-05-12 22:46:16 +07:00
Denozordec 8d5fd84962 fix(data-collection): добавить возможность отключения переключателей и улучшить обработку настроек
Docker images / prepare-release (push) Successful in 6s
Docker images / backend-image (push) Successful in 1m31s
Docker images / frontend-image (push) Successful in 1m41s
Docker images / updater-image (push) Successful in 37s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 7s
2026-05-12 22:37:55 +07:00
16 changed files with 367 additions and 106 deletions
+16
View File
@@ -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
+30 -5
View File
@@ -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`, раздел «Запуск».
+4
View File
@@ -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`.
+2 -1
View File
@@ -691,7 +691,8 @@ export default function DashboardPage() {
}
}
const loadingBlock = probesLoading && liveProbes === null
const liveDataPending = liveServersResolved === null || liveProbes === null
const loadingBlock = liveDataPending || (probesLoading && liveProbes === null)
const srvList = liveServersResolved ?? []
const totalSrv = srvList.length
const onlineSrv = srvList.filter((s) => s.status === "online").length
+205 -79
View File
@@ -79,15 +79,25 @@ function collectSettledErrors(results: PromiseSettledResult<unknown>[], labels:
return errors
}
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
function Toggle({
checked,
onChange,
disabled,
}: {
checked: boolean
onChange: (v: boolean) => void
disabled?: boolean
}) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
disabled={disabled}
onClick={() => onChange(!checked)}
className={cn(
"relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors cursor-pointer",
"relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors",
disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer",
checked ? "bg-primary" : "bg-input",
)}
>
@@ -750,6 +760,38 @@ function RunRowDetail({ r }: { r: SchedulerRunRowDto }) {
)
}
type SchedulerToggleOverrides = {
trafficEnabled?: boolean
serversApiEnabled?: boolean
resourcesEnabled?: boolean
pingEnabled?: boolean
speedEnabled?: boolean
internetPathEnabled?: boolean
certRenewEnabled?: boolean
}
function applySchedulerJobsToDrafts(
jobs: SchedulerJobStatusDto[],
setters: {
setDraftTrafficEnabled: (value: boolean) => void
setDraftServersApiEnabled: (value: boolean) => void
setDraftResourcesEnabled: (value: boolean) => void
setDraftPingEnabled: (value: boolean) => void
setDraftSpeedEnabled: (value: boolean) => void
setDraftInternetPathEnabled: (value: boolean) => void
setDraftCertRenewEnabled: (value: boolean) => void
},
) {
const byKey = Object.fromEntries(jobs.map((job) => [job.jobKey, job])) as Record<string, SchedulerJobStatusDto>
if (byKey.traffic) setters.setDraftTrafficEnabled(!!byKey.traffic.enabled)
if (byKey.servers_rest_ping) setters.setDraftServersApiEnabled(!!byKey.servers_rest_ping.enabled)
if (byKey.uptime_resources) setters.setDraftResourcesEnabled(!!byKey.uptime_resources.enabled)
if (byKey.uptime_ping) setters.setDraftPingEnabled(!!byKey.uptime_ping.enabled)
if (byKey.uptime_speed) setters.setDraftSpeedEnabled(!!byKey.uptime_speed.enabled)
if (byKey.internet_path) setters.setDraftInternetPathEnabled(!!byKey.internet_path.enabled)
if (byKey.certificates_renew) setters.setDraftCertRenewEnabled(!!byKey.certificates_renew.enabled)
}
export default function DataCollectionPage() {
const { mode, backendUrl, prefsHydrated } = useDataSource()
const isLive = prefsHydrated && mode === "live"
@@ -849,6 +891,17 @@ export default function DataCollectionPage() {
setDraftResourcesEnabled(!!(uptime.resourcesEnabled ?? uptime.enabled))
setDraftPingEnabled(!!(uptime.pingEnabled ?? uptime.enabled))
setDraftSpeedEnabled(!!(uptime.speedEnabled ?? uptime.enabled))
if (uptime.scheduler?.jobs?.length) {
applySchedulerJobsToDrafts(uptime.scheduler.jobs, {
setDraftTrafficEnabled,
setDraftServersApiEnabled,
setDraftResourcesEnabled,
setDraftPingEnabled,
setDraftSpeedEnabled,
setDraftInternetPathEnabled,
setDraftCertRenewEnabled,
})
}
}
const internetPath = readSettled(internetPathRes)
@@ -876,6 +929,138 @@ export default function DataCollectionPage() {
}
}, [apiFetch, isLive, runFilterJobKey])
const persistSchedulerDrafts = useCallback(async (overrides: SchedulerToggleOverrides = {}) => {
const trafficEnabled = overrides.trafficEnabled ?? draftTrafficEnabled
const serversApiEnabled = overrides.serversApiEnabled ?? draftServersApiEnabled
const resourcesEnabled = overrides.resourcesEnabled ?? draftResourcesEnabled
const pingEnabled = overrides.pingEnabled ?? draftPingEnabled
const speedEnabled = overrides.speedEnabled ?? draftSpeedEnabled
const internetPathEnabled = overrides.internetPathEnabled ?? draftInternetPathEnabled
const certRenewEnabled = overrides.certRenewEnabled ?? draftCertRenewEnabled
const tInt = Math.max(5, Number.parseInt(trafficIntervalDraft, 10) || 30)
const tRet = Math.max(1, Number.parseInt(trafficRetentionDraft, 10) || 14)
const uRes = Math.max(5, Number.parseInt(uptimeResourceIntervalDraft, 10) || 300)
const uPing = Math.max(5, Number.parseInt(uptimeIntervalDraft, 10) || 15)
const uSpd = Math.max(10, Number.parseInt(uptimeSpeedIntervalDraft, 10) || 60)
const uRet = Math.max(1, Number.parseInt(uptimeRetentionDraft, 10) || 14)
const sApiInt = Math.max(10, Number.parseInt(serversApiIntervalDraft, 10) || 120)
const ipInt = Math.max(30, Number.parseInt(internetPathIntervalDraft, 10) || 300)
const certRenewInt = Math.max(300, Number.parseInt(certRenewIntervalDraft, 10) || 21600)
const renewBeforeDays = Math.max(1, Math.min(90, Number.parseInt(renewBeforeDaysDraft, 10) || 30))
const saveResults = await Promise.allSettled([
apiFetch("/api/traffic/settings", {
method: "PUT",
body: JSON.stringify({
enabled: trafficEnabled,
intervalSec: tInt,
retentionDays: tRet,
}),
}),
apiFetch("/api/servers-api-ping/settings", {
method: "PUT",
body: JSON.stringify({
enabled: serversApiEnabled,
intervalSec: sApiInt,
}),
}),
apiFetch("/api/uptime/settings", {
method: "PUT",
body: JSON.stringify({
resourcesEnabled,
pingEnabled,
speedEnabled,
intervalSec: uRes,
probeIntervalSec: uPing,
speedIntervalSec: uSpd,
retentionDays: uRet,
}),
}),
apiFetch("/api/internet-path/settings", {
method: "PUT",
body: JSON.stringify({
enabled: internetPathEnabled,
intervalSec: ipInt,
}),
}),
apiFetch("/api/certificates/renew-settings", {
method: "PUT",
body: JSON.stringify({
enabled: certRenewEnabled,
intervalSec: certRenewInt,
renewBeforeDays,
}),
}),
])
const saveErrors = collectSettledErrors(
saveResults,
["трафик", "серверы REST API", "uptime", "internet path", "сертификаты"],
)
if (saveErrors.length > 0) {
throw new Error(`Не все настройки сохранились: ${saveErrors.join("; ")}`)
}
}, [
apiFetch,
certRenewIntervalDraft,
draftCertRenewEnabled,
draftInternetPathEnabled,
draftPingEnabled,
draftResourcesEnabled,
draftServersApiEnabled,
draftSpeedEnabled,
draftTrafficEnabled,
internetPathIntervalDraft,
renewBeforeDaysDraft,
serversApiIntervalDraft,
trafficIntervalDraft,
trafficRetentionDraft,
uptimeIntervalDraft,
uptimeResourceIntervalDraft,
uptimeRetentionDraft,
uptimeSpeedIntervalDraft,
])
const handleJobEnabledChange = useCallback(async (jobKey: (typeof SCHEDULER_JOB_KEYS)[number], nextEnabled: boolean) => {
const overrides: SchedulerToggleOverrides = {}
if (jobKey === "traffic") {
setDraftTrafficEnabled(nextEnabled)
overrides.trafficEnabled = nextEnabled
} else if (jobKey === "servers_rest_ping") {
setDraftServersApiEnabled(nextEnabled)
overrides.serversApiEnabled = nextEnabled
} else if (jobKey === "uptime_resources") {
setDraftResourcesEnabled(nextEnabled)
overrides.resourcesEnabled = nextEnabled
} else if (jobKey === "uptime_ping") {
setDraftPingEnabled(nextEnabled)
overrides.pingEnabled = nextEnabled
} else if (jobKey === "uptime_speed") {
setDraftSpeedEnabled(nextEnabled)
overrides.speedEnabled = nextEnabled
} else if (jobKey === "certificates_renew") {
setDraftCertRenewEnabled(nextEnabled)
overrides.certRenewEnabled = nextEnabled
} else if (jobKey === "internet_path") {
setDraftInternetPathEnabled(nextEnabled)
overrides.internetPathEnabled = nextEnabled
} else {
return
}
setCollectorError(null)
setSchedulerSaveBusy(true)
try {
await persistSchedulerDrafts(overrides)
await loadCollectors()
} catch (e) {
setCollectorError(e instanceof Error ? e.message : "Не удалось сохранить")
} finally {
setSchedulerSaveBusy(false)
}
}, [loadCollectors, persistSchedulerDrafts])
useEffect(() => {
if (!isLive) {
setTrafficCollector(null)
@@ -904,6 +1089,9 @@ export default function DataCollectionPage() {
}
const enabledJobsCount = useMemo(() => {
const jobs = uptimeCollector?.scheduler?.jobs
if (jobs?.length) return jobs.filter((job) => job.enabled).length
let n = draftTrafficEnabled ? 1 : 0
if (draftServersApiEnabled) n += 1
if (draftResourcesEnabled) n += 1
@@ -920,6 +1108,7 @@ export default function DataCollectionPage() {
draftServersApiEnabled,
draftSpeedEnabled,
draftTrafficEnabled,
uptimeCollector?.scheduler?.jobs,
])
const schedulerJobCount = SCHEDULER_JOB_KEYS.length
@@ -936,7 +1125,9 @@ export default function DataCollectionPage() {
{
label: "Включено задач",
value: `${enabledJobsCount} / ${schedulerJobCount}`,
sub: "По переключателям на этой странице (до сохранения)",
sub: uptimeCollector?.scheduler?.jobs?.length
? "По сохранённым задачам планировщика"
: "По переключателям на этой странице",
icon: <CalendarClockIcon className="size-4 text-muted-foreground" />,
},
{
@@ -1059,7 +1250,7 @@ export default function DataCollectionPage() {
<CardHeader className="border-b border-border pb-4">
<CardTitle className="text-base">Планировщик сбора данных</CardTitle>
<CardDescription className="text-xs">
Интервалы и вкл/выкл по задачам. Сохранение отправляет настройки на бекенд и перезапускает таймеры.
Интервалы и вкл/выкл по задачам. Переключатель сразу сохраняет задачу на бекенде; кнопка ниже интервалы и срок хранения.
</CardDescription>
</CardHeader>
<CardContent className="px-0 pb-0">
@@ -1085,15 +1276,15 @@ export default function DataCollectionPage() {
? draftTrafficEnabled
: jobKey === "servers_rest_ping"
? draftServersApiEnabled
: jobKey === "uptime_resources"
: jobKey === "uptime_resources"
? draftResourcesEnabled
: jobKey === "uptime_ping"
? draftPingEnabled
: jobKey === "uptime_speed"
? draftSpeedEnabled
: jobKey === "certificates_renew"
? draftCertRenewEnabled
: draftInternetPathEnabled
: jobKey === "uptime_speed"
? draftSpeedEnabled
: jobKey === "certificates_renew"
? draftCertRenewEnabled
: draftInternetPathEnabled
const iv = fixedSchedule
? String(j?.intervalSec ?? (jobKey === "gre_bgp" ? 30 : 20))
: jobKey === "traffic"
@@ -1152,15 +1343,10 @@ export default function DataCollectionPage() {
<span className={fixedSchedule ? "inline-flex pointer-events-none opacity-50" : "inline-flex"}>
<Toggle
checked={en}
disabled={fixedSchedule || schedulerSaveBusy}
onChange={(v) => {
if (fixedSchedule) return
if (jobKey === "traffic") setDraftTrafficEnabled(v)
else if (jobKey === "servers_rest_ping") setDraftServersApiEnabled(v)
else if (jobKey === "uptime_resources") setDraftResourcesEnabled(v)
else if (jobKey === "uptime_ping") setDraftPingEnabled(v)
else if (jobKey === "uptime_speed") setDraftSpeedEnabled(v)
else if (jobKey === "certificates_renew") setDraftCertRenewEnabled(v)
else setDraftInternetPathEnabled(v)
if (fixedSchedule || schedulerSaveBusy) return
void handleJobEnabledChange(jobKey, v)
}}
/>
</span>
@@ -1282,68 +1468,8 @@ export default function DataCollectionPage() {
setSchedulerSaveBusy(true)
setCollectorError(null)
try {
const tInt = Math.max(5, Number.parseInt(trafficIntervalDraft, 10) || 30)
const tRet = Math.max(1, Number.parseInt(trafficRetentionDraft, 10) || 14)
const uRes = Math.max(5, Number.parseInt(uptimeResourceIntervalDraft, 10) || 300)
const uPing = Math.max(5, Number.parseInt(uptimeIntervalDraft, 10) || 15)
const uSpd = Math.max(10, Number.parseInt(uptimeSpeedIntervalDraft, 10) || 60)
const uRet = Math.max(1, Number.parseInt(uptimeRetentionDraft, 10) || 14)
const sApiInt = Math.max(10, Number.parseInt(serversApiIntervalDraft, 10) || 120)
const ipInt = Math.max(30, Number.parseInt(internetPathIntervalDraft, 10) || 300)
const certRenewInt = Math.max(300, Number.parseInt(certRenewIntervalDraft, 10) || 21600)
const renewBeforeDays = Math.max(1, Math.min(90, Number.parseInt(renewBeforeDaysDraft, 10) || 30))
const saveResults = await Promise.allSettled([
apiFetch("/api/traffic/settings", {
method: "PUT",
body: JSON.stringify({
enabled: draftTrafficEnabled,
intervalSec: tInt,
retentionDays: tRet,
}),
}),
apiFetch("/api/servers-api-ping/settings", {
method: "PUT",
body: JSON.stringify({
enabled: draftServersApiEnabled,
intervalSec: sApiInt,
}),
}),
apiFetch("/api/uptime/settings", {
method: "PUT",
body: JSON.stringify({
resourcesEnabled: draftResourcesEnabled,
pingEnabled: draftPingEnabled,
speedEnabled: draftSpeedEnabled,
intervalSec: uRes,
probeIntervalSec: uPing,
speedIntervalSec: uSpd,
retentionDays: uRet,
}),
}),
apiFetch("/api/internet-path/settings", {
method: "PUT",
body: JSON.stringify({
enabled: draftInternetPathEnabled,
intervalSec: ipInt,
}),
}),
apiFetch("/api/certificates/renew-settings", {
method: "PUT",
body: JSON.stringify({
enabled: draftCertRenewEnabled,
intervalSec: certRenewInt,
renewBeforeDays,
}),
}),
])
const saveErrors = collectSettledErrors(
saveResults,
["трафик", "серверы REST API", "uptime", "internet path", "сертификаты"],
)
await persistSchedulerDrafts()
await loadCollectors()
if (saveErrors.length > 0) {
setCollectorError(`Не все настройки сохранились: ${saveErrors.join("; ")}`)
}
} catch (e) {
setCollectorError(e instanceof Error ? e.message : "Не удалось сохранить")
} finally {
+13
View File
@@ -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,
})
}
+10
View File
@@ -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" })
}
+2
View File
@@ -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",
+1 -1
View File
@@ -321,7 +321,7 @@ wait_for_health() {
while (( SECONDS < deadline )); do
if [[ "$health_type" == "http" ]]; then
local status
status="$(curl --silent --output /dev/null --write-out '%{http_code}' --max-time 5 "$health_url" || true)"
status="$(curl --silent --location --output /dev/null --write-out '%{http_code}' --max-time 5 "$health_url" || true)"
last_status="$status"
if [[ "$status" == "$expect_status" ]]; then
log info "target=${target_id} action=health_ok status=${status}"
+1 -1
View File
@@ -16,7 +16,7 @@
"image": "git.shts.su/denozord/mikrotikmanager-frontend:latest",
"health": {
"type": "http",
"url": "http://frontend:3000/",
"url": "http://frontend:3000/dashboard",
"expect_status": 200
}
}
+3
View File
@@ -0,0 +1,3 @@
export function backendInternalUrl(): string {
return (process.env.BACKEND_INTERNAL_URL ?? "http://127.0.0.1:8000").replace(/\/$/, "")
}
+3 -7
View File
@@ -59,13 +59,9 @@ function normalizeBackendUrl(url: string): string {
}
export function DataSourceProvider({ children }: { children: React.ReactNode }) {
const [mode, setModeState] = useState<DataSourceMode>(() =>
typeof window === "undefined" ? defaultDataSourceMode() : readStoredMode(),
)
const [backendUrl, setBackendUrlState] = useState(() =>
typeof window === "undefined" ? LOCAL_DEFAULT_BACKEND_URL : readStoredBackendUrl(),
)
const [prefsHydrated, setPrefsHydrated] = useState(() => typeof window !== "undefined")
const [mode, setModeState] = useState<DataSourceMode>(defaultDataSourceMode)
const [backendUrl, setBackendUrlState] = useState(LOCAL_DEFAULT_BACKEND_URL)
const [prefsHydrated, setPrefsHydrated] = useState(false)
const [backendStatus, setBackendStatus] = useState<boolean | undefined>(undefined)
const backendUrlLocked = isBackendUrlLocked()
const mockModeAvailable = isMockDataSourceAvailable()
+50
View File
@@ -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
View File
@@ -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*` },
];
},
};
+19 -4
View File
@@ -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)
+1 -1
View File
File diff suppressed because one or more lines are too long