Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
009011a917 | ||
|
|
399871f4f9 | ||
|
|
158fc36294 | ||
|
|
7dc4836c71 | ||
|
|
efc3812e12 | ||
|
|
8d5fd84962 | ||
|
|
f69e65b014 | ||
|
|
49d14a00af | ||
|
|
9ab2418a5f |
@@ -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
|
```powershell
|
||||||
npm run dev
|
npm run dev
|
||||||
npm --prefix backend run dev
|
npm --prefix backend run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
- Frontend: `http://localhost:3000`
|
| Сервис | URL | Проверка |
|
||||||
- Backend: `http://localhost:8000`
|
|--------|-----|----------|
|
||||||
- Health check backend: `http://localhost:8000/health`
|
| 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`.
|
4. Локальный hook `.githooks/commit-msg` отклоняет subject без кириллицы; подключение — `npm install` / `npm run prepare`.
|
||||||
|
|
||||||
Полные правила: `.cursor/rules/commit-messages-ru.mdc`, semver — `.cursor/rules/release-versioning.mdc`.
|
Полные правила: `.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`.
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ ENV NODE_ENV=production
|
|||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
ENV HOSTNAME=0.0.0.0
|
ENV HOSTNAME=0.0.0.0
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
|
ENV BACKEND_INTERNAL_URL=http://backend:8000
|
||||||
COPY --from=build /app/public ./public
|
COPY --from=build /app/public ./public
|
||||||
COPY --from=build /app/.next/standalone ./
|
COPY --from=build /app/.next/standalone ./
|
||||||
COPY --from=build /app/.next/static ./.next/static
|
COPY --from=build /app/.next/static ./.next/static
|
||||||
|
|||||||
@@ -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 srvList = liveServersResolved ?? []
|
||||||
const totalSrv = srvList.length
|
const totalSrv = srvList.length
|
||||||
const onlineSrv = srvList.filter((s) => s.status === "online").length
|
const onlineSrv = srvList.filter((s) => s.status === "online").length
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import {
|
|||||||
type SchedulerRunRowDto,
|
type SchedulerRunRowDto,
|
||||||
type UptimeSettingsDto,
|
type UptimeSettingsDto,
|
||||||
} from "@/lib/scheduler-settings"
|
} from "@/lib/scheduler-settings"
|
||||||
import { requestJson } from "@/shared/api/http-client"
|
import { requestJson, ApiClientError } from "@/shared/api/http-client"
|
||||||
import {
|
import {
|
||||||
parseSchedulerRunSnapshot,
|
parseSchedulerRunSnapshot,
|
||||||
type AlertEngineRuleDiagSnapshot,
|
type AlertEngineRuleDiagSnapshot,
|
||||||
@@ -58,15 +58,46 @@ function makeApiFetch(backendUrl: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
function extractApiError(reason: unknown): string {
|
||||||
|
if (reason instanceof ApiClientError) return reason.message
|
||||||
|
if (reason instanceof Error) return reason.message
|
||||||
|
return String(reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSettled<T>(result: PromiseSettledResult<T>): T | null {
|
||||||
|
return result.status === "fulfilled" ? result.value : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectSettledErrors(results: PromiseSettledResult<unknown>[], labels: string[]): string[] {
|
||||||
|
const errors: string[] = []
|
||||||
|
for (let i = 0; i < results.length; i += 1) {
|
||||||
|
const result = results[i]
|
||||||
|
if (result.status === "rejected") {
|
||||||
|
errors.push(`${labels[i]}: ${extractApiError(result.reason)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors
|
||||||
|
}
|
||||||
|
|
||||||
|
function Toggle({
|
||||||
|
checked,
|
||||||
|
onChange,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
checked: boolean
|
||||||
|
onChange: (v: boolean) => void
|
||||||
|
disabled?: boolean
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="switch"
|
role="switch"
|
||||||
aria-checked={checked}
|
aria-checked={checked}
|
||||||
|
disabled={disabled}
|
||||||
onClick={() => onChange(!checked)}
|
onClick={() => onChange(!checked)}
|
||||||
className={cn(
|
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",
|
checked ? "bg-primary" : "bg-input",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -729,9 +760,41 @@ 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() {
|
export default function DataCollectionPage() {
|
||||||
const { mode, backendUrl } = useDataSource()
|
const { mode, backendUrl, prefsHydrated } = useDataSource()
|
||||||
const isLive = mode === "live"
|
const isLive = prefsHydrated && mode === "live"
|
||||||
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
||||||
|
|
||||||
const [trafficCollector, setTrafficCollector] = useState<CollectorSettingsDto | null>(null)
|
const [trafficCollector, setTrafficCollector] = useState<CollectorSettingsDto | null>(null)
|
||||||
@@ -786,7 +849,7 @@ export default function DataCollectionPage() {
|
|||||||
runFilterJobKey && SCHEDULER_JOB_KEYS.includes(runFilterJobKey as (typeof SCHEDULER_JOB_KEYS)[number])
|
runFilterJobKey && SCHEDULER_JOB_KEYS.includes(runFilterJobKey as (typeof SCHEDULER_JOB_KEYS)[number])
|
||||||
? `?limit=80&jobKey=${encodeURIComponent(runFilterJobKey)}`
|
? `?limit=80&jobKey=${encodeURIComponent(runFilterJobKey)}`
|
||||||
: "?limit=80"
|
: "?limit=80"
|
||||||
const [traffic, serversApi, uptime, internetPath, certRenew, runsRes] = await Promise.all([
|
const [trafficRes, serversApiRes, uptimeRes, internetPathRes, certRenewRes, runsRes] = await Promise.allSettled([
|
||||||
apiFetch<CollectorSettingsDto>("/api/traffic/settings"),
|
apiFetch<CollectorSettingsDto>("/api/traffic/settings"),
|
||||||
apiFetch<CollectorSettingsDto>("/api/servers-api-ping/settings"),
|
apiFetch<CollectorSettingsDto>("/api/servers-api-ping/settings"),
|
||||||
apiFetch<UptimeSettingsDto>("/api/uptime/settings"),
|
apiFetch<UptimeSettingsDto>("/api/uptime/settings"),
|
||||||
@@ -794,28 +857,71 @@ export default function DataCollectionPage() {
|
|||||||
apiFetch<{ enabled: boolean; intervalSec: number; renewBeforeDays: number }>("/api/certificates/renew-settings"),
|
apiFetch<{ enabled: boolean; intervalSec: number; renewBeforeDays: number }>("/api/certificates/renew-settings"),
|
||||||
apiFetch<{ runs: SchedulerRunRowDto[] }>(`/api/scheduler/runs${runsQuery}`),
|
apiFetch<{ runs: SchedulerRunRowDto[] }>(`/api/scheduler/runs${runsQuery}`),
|
||||||
])
|
])
|
||||||
setTrafficCollector(traffic)
|
|
||||||
setServersApiCollector(serversApi)
|
const loadErrors = collectSettledErrors(
|
||||||
setUptimeCollector(uptime)
|
[trafficRes, serversApiRes, uptimeRes, internetPathRes, certRenewRes, runsRes],
|
||||||
setInternetPathCollector(internetPath)
|
["трафик", "серверы REST API", "uptime", "internet path", "сертификаты", "журнал планировщика"],
|
||||||
setSchedulerRuns(runsRes.runs ?? [])
|
)
|
||||||
setTrafficIntervalDraft(String(traffic.intervalSec))
|
if (loadErrors.length > 0) {
|
||||||
setTrafficRetentionDraft(String(traffic.retentionDays))
|
setCollectorError(loadErrors.join("; "))
|
||||||
setUptimeResourceIntervalDraft(String(uptime.intervalSec ?? 300))
|
}
|
||||||
setUptimeIntervalDraft(String(uptime.probeIntervalSec ?? 15))
|
|
||||||
setUptimeSpeedIntervalDraft(String(uptime.speedIntervalSec ?? 60))
|
const traffic = readSettled(trafficRes)
|
||||||
setUptimeRetentionDraft(String(uptime.retentionDays))
|
if (traffic) {
|
||||||
setDraftTrafficEnabled(!!traffic.enabled)
|
setTrafficCollector(traffic)
|
||||||
setDraftServersApiEnabled(!!serversApi.enabled)
|
setTrafficIntervalDraft(String(traffic.intervalSec))
|
||||||
setServersApiIntervalDraft(String(serversApi.intervalSec ?? 120))
|
setTrafficRetentionDraft(String(traffic.retentionDays))
|
||||||
setDraftResourcesEnabled(!!(uptime.resourcesEnabled ?? uptime.enabled))
|
setDraftTrafficEnabled(!!traffic.enabled)
|
||||||
setDraftPingEnabled(!!(uptime.pingEnabled ?? uptime.enabled))
|
}
|
||||||
setDraftSpeedEnabled(!!(uptime.speedEnabled ?? uptime.enabled))
|
|
||||||
setDraftInternetPathEnabled(!!internetPath.enabled)
|
const serversApi = readSettled(serversApiRes)
|
||||||
setInternetPathIntervalDraft(String(internetPath.intervalSec ?? 300))
|
if (serversApi) {
|
||||||
setDraftCertRenewEnabled(!!certRenew.enabled)
|
setServersApiCollector(serversApi)
|
||||||
setCertRenewIntervalDraft(String(certRenew.intervalSec ?? 21600))
|
setDraftServersApiEnabled(!!serversApi.enabled)
|
||||||
setRenewBeforeDaysDraft(String(certRenew.renewBeforeDays ?? 30))
|
setServersApiIntervalDraft(String(serversApi.intervalSec ?? 120))
|
||||||
|
}
|
||||||
|
|
||||||
|
const uptime = readSettled(uptimeRes)
|
||||||
|
if (uptime) {
|
||||||
|
setUptimeCollector(uptime)
|
||||||
|
setUptimeResourceIntervalDraft(String(uptime.intervalSec ?? 300))
|
||||||
|
setUptimeIntervalDraft(String(uptime.probeIntervalSec ?? 15))
|
||||||
|
setUptimeSpeedIntervalDraft(String(uptime.speedIntervalSec ?? 60))
|
||||||
|
setUptimeRetentionDraft(String(uptime.retentionDays))
|
||||||
|
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)
|
||||||
|
if (internetPath) {
|
||||||
|
setInternetPathCollector(internetPath)
|
||||||
|
setDraftInternetPathEnabled(!!internetPath.enabled)
|
||||||
|
setInternetPathIntervalDraft(String(internetPath.intervalSec ?? 300))
|
||||||
|
}
|
||||||
|
|
||||||
|
const certRenew = readSettled(certRenewRes)
|
||||||
|
if (certRenew) {
|
||||||
|
setDraftCertRenewEnabled(!!certRenew.enabled)
|
||||||
|
setCertRenewIntervalDraft(String(certRenew.intervalSec ?? 21600))
|
||||||
|
setRenewBeforeDaysDraft(String(certRenew.renewBeforeDays ?? 30))
|
||||||
|
}
|
||||||
|
|
||||||
|
const runsPayload = readSettled(runsRes)
|
||||||
|
if (runsPayload) {
|
||||||
|
setSchedulerRuns(runsPayload.runs ?? [])
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setCollectorError(e instanceof Error ? e.message : "Не удалось загрузить данные")
|
setCollectorError(e instanceof Error ? e.message : "Не удалось загрузить данные")
|
||||||
} finally {
|
} finally {
|
||||||
@@ -823,6 +929,138 @@ export default function DataCollectionPage() {
|
|||||||
}
|
}
|
||||||
}, [apiFetch, isLive, runFilterJobKey])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!isLive) {
|
if (!isLive) {
|
||||||
setTrafficCollector(null)
|
setTrafficCollector(null)
|
||||||
@@ -851,6 +1089,9 @@ export default function DataCollectionPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const enabledJobsCount = useMemo(() => {
|
const enabledJobsCount = useMemo(() => {
|
||||||
|
const jobs = uptimeCollector?.scheduler?.jobs
|
||||||
|
if (jobs?.length) return jobs.filter((job) => job.enabled).length
|
||||||
|
|
||||||
let n = draftTrafficEnabled ? 1 : 0
|
let n = draftTrafficEnabled ? 1 : 0
|
||||||
if (draftServersApiEnabled) n += 1
|
if (draftServersApiEnabled) n += 1
|
||||||
if (draftResourcesEnabled) n += 1
|
if (draftResourcesEnabled) n += 1
|
||||||
@@ -867,6 +1108,7 @@ export default function DataCollectionPage() {
|
|||||||
draftServersApiEnabled,
|
draftServersApiEnabled,
|
||||||
draftSpeedEnabled,
|
draftSpeedEnabled,
|
||||||
draftTrafficEnabled,
|
draftTrafficEnabled,
|
||||||
|
uptimeCollector?.scheduler?.jobs,
|
||||||
])
|
])
|
||||||
|
|
||||||
const schedulerJobCount = SCHEDULER_JOB_KEYS.length
|
const schedulerJobCount = SCHEDULER_JOB_KEYS.length
|
||||||
@@ -883,7 +1125,9 @@ export default function DataCollectionPage() {
|
|||||||
{
|
{
|
||||||
label: "Включено задач",
|
label: "Включено задач",
|
||||||
value: `${enabledJobsCount} / ${schedulerJobCount}`,
|
value: `${enabledJobsCount} / ${schedulerJobCount}`,
|
||||||
sub: "По переключателям на этой странице (до сохранения)",
|
sub: uptimeCollector?.scheduler?.jobs?.length
|
||||||
|
? "По сохранённым задачам планировщика"
|
||||||
|
: "По переключателям на этой странице",
|
||||||
icon: <CalendarClockIcon className="size-4 text-muted-foreground" />,
|
icon: <CalendarClockIcon className="size-4 text-muted-foreground" />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -950,7 +1194,18 @@ export default function DataCollectionPage() {
|
|||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
<div className="flex flex-col gap-5 max-w-[1100px] mx-auto w-full">
|
<div className="flex flex-col gap-5 max-w-[1100px] mx-auto w-full">
|
||||||
{!isLive && (
|
{!prefsHydrated && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Загрузка настроек подключения</CardTitle>
|
||||||
|
<CardDescription className="text-xs">
|
||||||
|
Читаем режим данных и адрес API из локальных настроек.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{prefsHydrated && !isLive && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">Нужен live-режим</CardTitle>
|
<CardTitle className="text-base">Нужен live-режим</CardTitle>
|
||||||
@@ -995,7 +1250,7 @@ export default function DataCollectionPage() {
|
|||||||
<CardHeader className="border-b border-border pb-4">
|
<CardHeader className="border-b border-border pb-4">
|
||||||
<CardTitle className="text-base">Планировщик сбора данных</CardTitle>
|
<CardTitle className="text-base">Планировщик сбора данных</CardTitle>
|
||||||
<CardDescription className="text-xs">
|
<CardDescription className="text-xs">
|
||||||
Интервалы и вкл/выкл по задачам. Сохранение отправляет настройки на бекенд и перезапускает таймеры.
|
Интервалы и вкл/выкл по задачам. Переключатель сразу сохраняет задачу на бекенде; кнопка ниже — интервалы и срок хранения.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="px-0 pb-0">
|
<CardContent className="px-0 pb-0">
|
||||||
@@ -1021,15 +1276,15 @@ export default function DataCollectionPage() {
|
|||||||
? draftTrafficEnabled
|
? draftTrafficEnabled
|
||||||
: jobKey === "servers_rest_ping"
|
: jobKey === "servers_rest_ping"
|
||||||
? draftServersApiEnabled
|
? draftServersApiEnabled
|
||||||
: jobKey === "uptime_resources"
|
: jobKey === "uptime_resources"
|
||||||
? draftResourcesEnabled
|
? draftResourcesEnabled
|
||||||
: jobKey === "uptime_ping"
|
: jobKey === "uptime_ping"
|
||||||
? draftPingEnabled
|
? draftPingEnabled
|
||||||
: jobKey === "uptime_speed"
|
: jobKey === "uptime_speed"
|
||||||
? draftSpeedEnabled
|
? draftSpeedEnabled
|
||||||
: jobKey === "certificates_renew"
|
: jobKey === "certificates_renew"
|
||||||
? draftCertRenewEnabled
|
? draftCertRenewEnabled
|
||||||
: draftInternetPathEnabled
|
: draftInternetPathEnabled
|
||||||
const iv = fixedSchedule
|
const iv = fixedSchedule
|
||||||
? String(j?.intervalSec ?? (jobKey === "gre_bgp" ? 30 : 20))
|
? String(j?.intervalSec ?? (jobKey === "gre_bgp" ? 30 : 20))
|
||||||
: jobKey === "traffic"
|
: jobKey === "traffic"
|
||||||
@@ -1088,15 +1343,10 @@ export default function DataCollectionPage() {
|
|||||||
<span className={fixedSchedule ? "inline-flex pointer-events-none opacity-50" : "inline-flex"}>
|
<span className={fixedSchedule ? "inline-flex pointer-events-none opacity-50" : "inline-flex"}>
|
||||||
<Toggle
|
<Toggle
|
||||||
checked={en}
|
checked={en}
|
||||||
|
disabled={fixedSchedule || schedulerSaveBusy}
|
||||||
onChange={(v) => {
|
onChange={(v) => {
|
||||||
if (fixedSchedule) return
|
if (fixedSchedule || schedulerSaveBusy) return
|
||||||
if (jobKey === "traffic") setDraftTrafficEnabled(v)
|
void handleJobEnabledChange(jobKey, 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)
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
@@ -1218,58 +1468,7 @@ export default function DataCollectionPage() {
|
|||||||
setSchedulerSaveBusy(true)
|
setSchedulerSaveBusy(true)
|
||||||
setCollectorError(null)
|
setCollectorError(null)
|
||||||
try {
|
try {
|
||||||
const tInt = Math.max(5, Number.parseInt(trafficIntervalDraft, 10) || 30)
|
await persistSchedulerDrafts()
|
||||||
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)
|
|
||||||
await apiFetch("/api/traffic/settings", {
|
|
||||||
method: "PUT",
|
|
||||||
body: JSON.stringify({
|
|
||||||
enabled: draftTrafficEnabled,
|
|
||||||
intervalSec: tInt,
|
|
||||||
retentionDays: tRet,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
await apiFetch("/api/servers-api-ping/settings", {
|
|
||||||
method: "PUT",
|
|
||||||
body: JSON.stringify({
|
|
||||||
enabled: draftServersApiEnabled,
|
|
||||||
intervalSec: sApiInt,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
await apiFetch("/api/uptime/settings", {
|
|
||||||
method: "PUT",
|
|
||||||
body: JSON.stringify({
|
|
||||||
resourcesEnabled: draftResourcesEnabled,
|
|
||||||
pingEnabled: draftPingEnabled,
|
|
||||||
speedEnabled: draftSpeedEnabled,
|
|
||||||
intervalSec: uRes,
|
|
||||||
probeIntervalSec: uPing,
|
|
||||||
speedIntervalSec: uSpd,
|
|
||||||
retentionDays: uRet,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
const certRenewInt = Math.max(300, Number.parseInt(certRenewIntervalDraft, 10) || 21600)
|
|
||||||
const renewBeforeDays = Math.max(1, Math.min(90, Number.parseInt(renewBeforeDaysDraft, 10) || 30))
|
|
||||||
await apiFetch("/api/internet-path/settings", {
|
|
||||||
method: "PUT",
|
|
||||||
body: JSON.stringify({
|
|
||||||
enabled: draftInternetPathEnabled,
|
|
||||||
intervalSec: ipInt,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
await apiFetch("/api/certificates/renew-settings", {
|
|
||||||
method: "PUT",
|
|
||||||
body: JSON.stringify({
|
|
||||||
enabled: draftCertRenewEnabled,
|
|
||||||
intervalSec: certRenewInt,
|
|
||||||
renewBeforeDays,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
await loadCollectors()
|
await loadCollectors()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setCollectorError(e instanceof Error ? e.message : "Не удалось сохранить")
|
setCollectorError(e instanceof Error ? e.message : "Не удалось сохранить")
|
||||||
|
|||||||
@@ -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" })
|
||||||
|
}
|
||||||
@@ -4,3 +4,4 @@ dist/
|
|||||||
*.db-shm
|
*.db-shm
|
||||||
*.db-wal
|
*.db-wal
|
||||||
.env
|
.env
|
||||||
|
storage/backups/
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import Database from "better-sqlite3"
|
import Database from "better-sqlite3"
|
||||||
|
import { existsSync, readFileSync } from "node:fs"
|
||||||
|
import path from "node:path"
|
||||||
|
|
||||||
type SqliteHandle = InstanceType<typeof Database>
|
type SqliteHandle = InstanceType<typeof Database>
|
||||||
import { drizzle } from "drizzle-orm/better-sqlite3"
|
import { drizzle } from "drizzle-orm/better-sqlite3"
|
||||||
@@ -391,6 +393,19 @@ CREATE TABLE IF NOT EXISTS backup_schedule_settings (
|
|||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS backup_entries (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
server_id TEXT NOT NULL,
|
||||||
|
server_name TEXT NOT NULL,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
size_bytes INTEGER NOT NULL,
|
||||||
|
kind TEXT NOT NULL DEFAULT 'manual',
|
||||||
|
notes TEXT,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_backup_entries_server_created ON backup_entries(server_id, created_at);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_backup_entries_filename ON backup_entries(filename);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS alert_rules (
|
CREATE TABLE IF NOT EXISTS alert_rules (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
@@ -669,6 +684,41 @@ SELECT 1, NULL
|
|||||||
WHERE NOT EXISTS (SELECT 1 FROM alert_engine_cursor WHERE id = 1);
|
WHERE NOT EXISTS (SELECT 1 FROM alert_engine_cursor WHERE id = 1);
|
||||||
`)
|
`)
|
||||||
|
|
||||||
|
const backupEntryCount = sqlite.prepare(`SELECT COUNT(*) AS c FROM backup_entries`).get() as { c: number }
|
||||||
|
if (backupEntryCount.c === 0) {
|
||||||
|
const legacyIndexPath = path.resolve(process.cwd(), "storage", "backups", "index.json")
|
||||||
|
if (existsSync(legacyIndexPath)) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(readFileSync(legacyIndexPath, "utf8")) as unknown
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
const insert = sqlite.prepare(`
|
||||||
|
INSERT OR IGNORE INTO backup_entries (id, server_id, server_name, filename, size_bytes, kind, notes, created_at)
|
||||||
|
VALUES (@id, @serverId, @serverName, @filename, @sizeBytes, @kind, @notes, @createdAt)
|
||||||
|
`)
|
||||||
|
for (const row of parsed) {
|
||||||
|
if (!row || typeof row !== "object") continue
|
||||||
|
const item = row as Record<string, unknown>
|
||||||
|
const id = String(item.id ?? "").trim()
|
||||||
|
const filename = String(item.filename ?? "").trim()
|
||||||
|
if (!id || !filename) continue
|
||||||
|
insert.run({
|
||||||
|
id,
|
||||||
|
serverId: String(item.serverId ?? ""),
|
||||||
|
serverName: String(item.serverName ?? ""),
|
||||||
|
filename,
|
||||||
|
sizeBytes: Number(item.sizeBytes ?? 0) || 0,
|
||||||
|
kind: item.kind === "auto" ? "auto" : "manual",
|
||||||
|
notes: item.notes == null ? null : String(item.notes),
|
||||||
|
createdAt: String(item.createdAt ?? new Date().toISOString()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* legacy index.json не читается — пропускаем */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const db = drizzle(sqlite, { schema })
|
export const db = drizzle(sqlite, { schema })
|
||||||
|
|
||||||
/** Прямой доступ к better-sqlite3 для сложных read-only запросов (напр. /api/alerts). */
|
/** Прямой доступ к better-sqlite3 для сложных read-only запросов (напр. /api/alerts). */
|
||||||
|
|||||||
@@ -340,6 +340,17 @@ export const backupScheduleSettings = sqliteTable("backup_schedule_settings", {
|
|||||||
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
|
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const backupEntries = sqliteTable("backup_entries", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
serverId: text("server_id").notNull(),
|
||||||
|
serverName: text("server_name").notNull(),
|
||||||
|
filename: text("filename").notNull(),
|
||||||
|
sizeBytes: integer("size_bytes").notNull(),
|
||||||
|
kind: text("kind", { enum: ["manual", "auto"] }).notNull().default("manual"),
|
||||||
|
notes: text("notes"),
|
||||||
|
createdAt: text("created_at").notNull(),
|
||||||
|
})
|
||||||
|
|
||||||
/** Группы правил: ANY = хотя бы одно; ALL = все одновременно в окне тика. */
|
/** Группы правил: ANY = хотя бы одно; ALL = все одновременно в окне тика. */
|
||||||
export const alertGroups = sqliteTable("alert_groups", {
|
export const alertGroups = sqliteTable("alert_groups", {
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
@@ -562,6 +573,7 @@ export type AcmeSettingsRow = typeof acmeSettings.$inferSelect
|
|||||||
export type CertificateIssueJobRow = typeof certificateIssueJobs.$inferSelect
|
export type CertificateIssueJobRow = typeof certificateIssueJobs.$inferSelect
|
||||||
export type CertificateRenewSettingsRow = typeof certificateRenewSettings.$inferSelect
|
export type CertificateRenewSettingsRow = typeof certificateRenewSettings.$inferSelect
|
||||||
export type BackupScheduleSettingsRow = typeof backupScheduleSettings.$inferSelect
|
export type BackupScheduleSettingsRow = typeof backupScheduleSettings.$inferSelect
|
||||||
|
export type BackupEntryRow = typeof backupEntries.$inferSelect
|
||||||
export type AlertGroupRow = typeof alertGroups.$inferSelect
|
export type AlertGroupRow = typeof alertGroups.$inferSelect
|
||||||
export type AlertRuleRow = typeof alertRules.$inferSelect
|
export type AlertRuleRow = typeof alertRules.$inferSelect
|
||||||
export type AlertRuleTargetRow = typeof alertRuleTargets.$inferSelect
|
export type AlertRuleTargetRow = typeof alertRuleTargets.$inferSelect
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ import { refreshScheduler, stopScheduler } from "./services/scheduler.js"
|
|||||||
// ── app factory ────────────────────────────────────────────────────────────────
|
// ── app factory ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const app = Fastify({
|
const app = Fastify({
|
||||||
|
bodyLimit: 512 * 1024 * 1024,
|
||||||
|
requestTimeout: 10 * 60 * 1000,
|
||||||
logger: {
|
logger: {
|
||||||
transport: {
|
transport: {
|
||||||
target: "pino-pretty",
|
target: "pino-pretty",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { randomUUID } from "node:crypto"
|
import { randomUUID } from "node:crypto"
|
||||||
import { readFile, rm } from "node:fs/promises"
|
import { readFile } from "node:fs/promises"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||||
@@ -8,12 +8,13 @@ import { listServersRead } from "../modules/servers/service/servers-service.js"
|
|||||||
import { appendEvent } from "../modules/events/service/events-service.js"
|
import { appendEvent } from "../modules/events/service/events-service.js"
|
||||||
import { refreshScheduler } from "../services/scheduler.js"
|
import { refreshScheduler } from "../services/scheduler.js"
|
||||||
import {
|
import {
|
||||||
|
deleteBackupRecord,
|
||||||
|
getBackupById,
|
||||||
getBackupsDir,
|
getBackupsDir,
|
||||||
getBackupScheduleSettings,
|
getBackupScheduleSettings,
|
||||||
readBackupIndex,
|
listBackups,
|
||||||
runBackupForServer,
|
runBackupForServer,
|
||||||
updateBackupScheduleSettings,
|
updateBackupScheduleSettings,
|
||||||
writeBackupIndex,
|
|
||||||
type BackupMeta,
|
type BackupMeta,
|
||||||
} from "../services/backup-service.js"
|
} from "../services/backup-service.js"
|
||||||
|
|
||||||
@@ -47,11 +48,9 @@ const BackupJobIdParamSchema = z.object({
|
|||||||
async function processBackupJob(job: BackupJob, ids: string[], notes?: string) {
|
async function processBackupJob(job: BackupJob, ids: string[], notes?: string) {
|
||||||
job.status = "running"
|
job.status = "running"
|
||||||
job.startedAt = new Date().toISOString()
|
job.startedAt = new Date().toISOString()
|
||||||
const indexRows = await readBackupIndex()
|
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
try {
|
try {
|
||||||
const meta = await runBackupForServer(id, "manual", notes)
|
const meta = await runBackupForServer(id, "manual", notes)
|
||||||
indexRows.unshift(meta)
|
|
||||||
job.created.push(meta)
|
job.created.push(meta)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
@@ -60,7 +59,6 @@ async function processBackupJob(job: BackupJob, ids: string[], notes?: string) {
|
|||||||
job.completed += 1
|
job.completed += 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await writeBackupIndex(indexRows)
|
|
||||||
job.status = "done"
|
job.status = "done"
|
||||||
job.finishedAt = new Date().toISOString()
|
job.finishedAt = new Date().toISOString()
|
||||||
appendEvent({
|
appendEvent({
|
||||||
@@ -82,9 +80,7 @@ async function processBackupJob(job: BackupJob, ids: string[], notes?: string) {
|
|||||||
|
|
||||||
const backupsRoutes: FastifyPluginAsyncZod = async (app) => {
|
const backupsRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||||
app.get("/backups", async (_req, reply) => {
|
app.get("/backups", async (_req, reply) => {
|
||||||
const rows = await readBackupIndex()
|
return reply.send(listBackups())
|
||||||
rows.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
|
|
||||||
return reply.send(rows)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
app.get("/backups/schedule", async (_req, reply) => {
|
app.get("/backups/schedule", async (_req, reply) => {
|
||||||
@@ -171,8 +167,7 @@ const backupsRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
app.get("/backups/:id/download", { schema: { params: BackupIdParamSchema } }, async (req, reply) => {
|
app.get("/backups/:id/download", { schema: { params: BackupIdParamSchema } }, async (req, reply) => {
|
||||||
const rows = await readBackupIndex()
|
const hit = getBackupById(req.params.id)
|
||||||
const hit = rows.find((r) => r.id === req.params.id)
|
|
||||||
if (!hit) return reply.status(404).send({ error: "Бэкап не найден" })
|
if (!hit) return reply.status(404).send({ error: "Бэкап не найден" })
|
||||||
const filePath = path.join(getBackupsDir(), hit.filename)
|
const filePath = path.join(getBackupsDir(), hit.filename)
|
||||||
const content = await readFile(filePath, "utf8").catch(() => null)
|
const content = await readFile(filePath, "utf8").catch(() => null)
|
||||||
@@ -183,12 +178,8 @@ const backupsRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
app.delete("/backups/:id", { schema: { params: BackupIdParamSchema } }, async (req, reply) => {
|
app.delete("/backups/:id", { schema: { params: BackupIdParamSchema } }, async (req, reply) => {
|
||||||
const rows = await readBackupIndex()
|
const hit = await deleteBackupRecord(req.params.id)
|
||||||
const idx = rows.findIndex((r) => r.id === req.params.id)
|
if (!hit) return reply.status(404).send({ error: "Бэкап не найден" })
|
||||||
if (idx < 0) return reply.status(404).send({ error: "Бэкап не найден" })
|
|
||||||
const [hit] = rows.splice(idx, 1)
|
|
||||||
await writeBackupIndex(rows)
|
|
||||||
await rm(path.join(getBackupsDir(), hit.filename), { force: true })
|
|
||||||
return reply.status(204).send()
|
return reply.status(204).send()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,9 @@ import {
|
|||||||
getBackupScheduleSettings,
|
getBackupScheduleSettings,
|
||||||
isBackupDue,
|
isBackupDue,
|
||||||
pruneBackupsForServer,
|
pruneBackupsForServer,
|
||||||
readBackupIndex,
|
|
||||||
resolveBackupServerIds,
|
resolveBackupServerIds,
|
||||||
runBackupForServer,
|
runBackupForServer,
|
||||||
touchBackupScheduleRunMeta,
|
touchBackupScheduleRunMeta,
|
||||||
writeBackupIndex,
|
|
||||||
} from "./backup-service.js"
|
} from "./backup-service.js"
|
||||||
|
|
||||||
let collecting = false
|
let collecting = false
|
||||||
@@ -62,7 +60,6 @@ export async function collectScheduledBackupsOnce(): Promise<BackupsRunSnapshot>
|
|||||||
collecting = true
|
collecting = true
|
||||||
const started = Date.now()
|
const started = Date.now()
|
||||||
const serverIds = resolveBackupServerIds(settings)
|
const serverIds = resolveBackupServerIds(settings)
|
||||||
const indexRows = await readBackupIndex()
|
|
||||||
|
|
||||||
appendEvent({
|
appendEvent({
|
||||||
level: "info",
|
level: "info",
|
||||||
@@ -78,8 +75,7 @@ export async function collectScheduledBackupsOnce(): Promise<BackupsRunSnapshot>
|
|||||||
try {
|
try {
|
||||||
for (const id of serverIds) {
|
for (const id of serverIds) {
|
||||||
try {
|
try {
|
||||||
const meta = await runBackupForServer(id, "auto")
|
await runBackupForServer(id, "auto")
|
||||||
indexRows.unshift(meta)
|
|
||||||
snapshot.created += 1
|
snapshot.created += 1
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const message = e instanceof Error ? e.message : String(e)
|
const message = e instanceof Error ? e.message : String(e)
|
||||||
@@ -88,8 +84,6 @@ export async function collectScheduledBackupsOnce(): Promise<BackupsRunSnapshot>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await writeBackupIndex(indexRows)
|
|
||||||
|
|
||||||
for (const id of serverIds) {
|
for (const id of serverIds) {
|
||||||
snapshot.pruned += await pruneBackupsForServer(id, settings.keepCount)
|
snapshot.pruned += await pruneBackupsForServer(id, settings.keepCount)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
import { randomUUID } from "node:crypto"
|
import { randomUUID } from "node:crypto"
|
||||||
import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises"
|
import { mkdir, rm, stat, writeFile } from "node:fs/promises"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
import { eq } from "drizzle-orm"
|
import { desc, eq } from "drizzle-orm"
|
||||||
import type { BackupScheduleSettingsDto } from "@mmapp/contracts/backups"
|
import type { BackupScheduleSettingsDto } from "@mmapp/contracts/backups"
|
||||||
import { db } from "../db/index.js"
|
import { db } from "../db/index.js"
|
||||||
import { backupScheduleSettings } from "../db/schema.js"
|
import { backupEntries, backupScheduleSettings } from "../db/schema.js"
|
||||||
import { getServerRowById } from "../modules/servers/repository/servers-repository.js"
|
import { getServerRowById } from "../modules/servers/repository/servers-repository.js"
|
||||||
import { listServersRead } from "../modules/servers/service/servers-service.js"
|
import { listServersRead } from "../modules/servers/service/servers-service.js"
|
||||||
import { MikrotikClient } from "./mikrotik.js"
|
import { MikrotikClient } from "./mikrotik.js"
|
||||||
|
|
||||||
const SETTINGS_ID = 1
|
const SETTINGS_ID = 1
|
||||||
const BACKUPS_DIR = path.resolve(process.cwd(), "storage", "backups")
|
const BACKUPS_DIR = path.resolve(process.cwd(), "storage", "backups")
|
||||||
const INDEX_PATH = path.join(BACKUPS_DIR, "index.json")
|
|
||||||
|
|
||||||
export type BackupMeta = {
|
export type BackupMeta = {
|
||||||
id: string
|
id: string
|
||||||
@@ -24,25 +23,51 @@ export type BackupMeta = {
|
|||||||
notes?: string
|
notes?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function rowToMeta(row: typeof backupEntries.$inferSelect): BackupMeta {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
serverId: row.serverId,
|
||||||
|
serverName: row.serverName,
|
||||||
|
filename: row.filename,
|
||||||
|
sizeBytes: row.sizeBytes,
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
kind: row.kind,
|
||||||
|
notes: row.notes ?? undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function ensureBackupStorage(): Promise<void> {
|
export async function ensureBackupStorage(): Promise<void> {
|
||||||
await mkdir(BACKUPS_DIR, { recursive: true })
|
await mkdir(BACKUPS_DIR, { recursive: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function readBackupIndex(): Promise<BackupMeta[]> {
|
export function listBackups(): BackupMeta[] {
|
||||||
await ensureBackupStorage()
|
return db.select().from(backupEntries).orderBy(desc(backupEntries.createdAt)).all().map(rowToMeta)
|
||||||
try {
|
|
||||||
const raw = await readFile(INDEX_PATH, "utf8")
|
|
||||||
const parsed = JSON.parse(raw) as unknown
|
|
||||||
if (!Array.isArray(parsed)) return []
|
|
||||||
return parsed as BackupMeta[]
|
|
||||||
} catch {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function writeBackupIndex(rows: BackupMeta[]): Promise<void> {
|
export function getBackupById(id: string): BackupMeta | null {
|
||||||
await ensureBackupStorage()
|
const row = db.select().from(backupEntries).where(eq(backupEntries.id, id)).limit(1).all()[0]
|
||||||
await writeFile(INDEX_PATH, JSON.stringify(rows, null, 2), "utf8")
|
return row ? rowToMeta(row) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function insertBackup(meta: BackupMeta): void {
|
||||||
|
db.insert(backupEntries).values({
|
||||||
|
id: meta.id,
|
||||||
|
serverId: meta.serverId,
|
||||||
|
serverName: meta.serverName,
|
||||||
|
filename: meta.filename,
|
||||||
|
sizeBytes: meta.sizeBytes,
|
||||||
|
kind: meta.kind,
|
||||||
|
notes: meta.notes ?? null,
|
||||||
|
createdAt: meta.createdAt,
|
||||||
|
}).run()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteBackupRecord(id: string): Promise<BackupMeta | null> {
|
||||||
|
const hit = getBackupById(id)
|
||||||
|
if (!hit) return null
|
||||||
|
db.delete(backupEntries).where(eq(backupEntries.id, id)).run()
|
||||||
|
await rm(path.join(BACKUPS_DIR, hit.filename), { force: true })
|
||||||
|
return hit
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtTs(d = new Date()): string {
|
function fmtTs(d = new Date()): string {
|
||||||
@@ -152,7 +177,22 @@ export function resolveBackupServerIds(settings: BackupScheduleSettingsDto): str
|
|||||||
return [...new Set(requested)].filter((id) => enabled.has(id))
|
return [...new Set(requested)].filter((id) => enabled.has(id))
|
||||||
}
|
}
|
||||||
|
|
||||||
function sameLocalSlot(a: Date, b: Date): boolean {
|
function scheduledSlotForDate(now: Date, settings: BackupScheduleSettingsDto): Date | null {
|
||||||
|
if (settings.frequency === "weekly") {
|
||||||
|
const currentDow = (now.getDay() + 6) % 7
|
||||||
|
if (currentDow !== settings.weekDay) return null
|
||||||
|
} else if (settings.frequency === "monthly") {
|
||||||
|
if (now.getDate() !== settings.monthDay) return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const slot = new Date(now)
|
||||||
|
slot.setSeconds(0, 0)
|
||||||
|
slot.setMilliseconds(0)
|
||||||
|
slot.setHours(settings.hour, settings.minute, 0, 0)
|
||||||
|
return slot
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameLocalMinute(a: Date, b: Date): boolean {
|
||||||
return a.getFullYear() === b.getFullYear()
|
return a.getFullYear() === b.getFullYear()
|
||||||
&& a.getMonth() === b.getMonth()
|
&& a.getMonth() === b.getMonth()
|
||||||
&& a.getDate() === b.getDate()
|
&& a.getDate() === b.getDate()
|
||||||
@@ -160,27 +200,21 @@ function sameLocalSlot(a: Date, b: Date): boolean {
|
|||||||
&& a.getMinutes() === b.getMinutes()
|
&& a.getMinutes() === b.getMinutes()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Срабатывает только в минуту расписания; 60 с в UI — интервал проверки, не частота бэкапа. */
|
||||||
export function isBackupDue(now: Date, settings: BackupScheduleSettingsDto, lastRunAt: string | null | undefined): boolean {
|
export function isBackupDue(now: Date, settings: BackupScheduleSettingsDto, lastRunAt: string | null | undefined): boolean {
|
||||||
if (!settings.enabled) return false
|
if (!settings.enabled) return false
|
||||||
const slot = new Date(now)
|
|
||||||
slot.setSeconds(0, 0)
|
|
||||||
slot.setHours(settings.hour, settings.minute, 0, 0)
|
|
||||||
|
|
||||||
if (settings.frequency === "weekly") {
|
|
||||||
const currentDow = (now.getDay() + 6) % 7
|
|
||||||
if (currentDow !== settings.weekDay) return false
|
|
||||||
} else if (settings.frequency === "monthly") {
|
|
||||||
if (now.getDate() !== settings.monthDay) return false
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const slot = scheduledSlotForDate(now, settings)
|
||||||
|
if (!slot) return false
|
||||||
if (now < slot) return false
|
if (now < slot) return false
|
||||||
|
if (!sameLocalMinute(now, slot)) return false
|
||||||
|
|
||||||
if (lastRunAt) {
|
if (lastRunAt) {
|
||||||
const prev = new Date(lastRunAt)
|
const prev = new Date(lastRunAt)
|
||||||
if (Number.isNaN(prev.getTime())) return true
|
if (Number.isNaN(prev.getTime())) return true
|
||||||
if (settings.frequency === "daily" && sameLocalSlot(prev, slot)) return false
|
if (sameLocalMinute(prev, slot)) return false
|
||||||
if (settings.frequency === "weekly" && sameLocalSlot(prev, slot)) return false
|
|
||||||
if (settings.frequency === "monthly" && prev.getFullYear() === slot.getFullYear() && prev.getMonth() === slot.getMonth() && prev.getDate() === slot.getDate()) return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,9 +237,10 @@ export async function runBackupForServer(
|
|||||||
const safeServer = row.name.replace(/[^a-zA-Z0-9._-]+/g, "_")
|
const safeServer = row.name.replace(/[^a-zA-Z0-9._-]+/g, "_")
|
||||||
const filename = `${safeServer}_${ts}.rsc`
|
const filename = `${safeServer}_${ts}.rsc`
|
||||||
const filePath = path.join(BACKUPS_DIR, filename)
|
const filePath = path.join(BACKUPS_DIR, filename)
|
||||||
|
await ensureBackupStorage()
|
||||||
await writeFile(filePath, script, "utf8")
|
await writeFile(filePath, script, "utf8")
|
||||||
const st = await stat(filePath)
|
const st = await stat(filePath)
|
||||||
return {
|
const meta: BackupMeta = {
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
serverId: String(row.id),
|
serverId: String(row.id),
|
||||||
serverName: row.name,
|
serverName: row.name,
|
||||||
@@ -215,27 +250,24 @@ export async function runBackupForServer(
|
|||||||
kind,
|
kind,
|
||||||
notes,
|
notes,
|
||||||
}
|
}
|
||||||
|
insertBackup(meta)
|
||||||
|
return meta
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function pruneBackupsForServer(serverId: string, keepCount: number): Promise<number> {
|
export async function pruneBackupsForServer(serverId: string, keepCount: number): Promise<number> {
|
||||||
const rows = await readBackupIndex()
|
const rows = db.select().from(backupEntries)
|
||||||
const forServer = rows.filter((r) => r.serverId === serverId)
|
.where(eq(backupEntries.serverId, serverId))
|
||||||
if (forServer.length <= keepCount) return 0
|
.orderBy(desc(backupEntries.createdAt))
|
||||||
const sorted = [...forServer].sort((a, b) => b.createdAt.localeCompare(a.createdAt))
|
.all()
|
||||||
const toDelete = sorted.slice(keepCount)
|
if (rows.length <= keepCount) return 0
|
||||||
const deleteIds = new Set(toDelete.map((r) => r.id))
|
const toDelete = rows.slice(keepCount)
|
||||||
for (const hit of toDelete) {
|
for (const hit of toDelete) {
|
||||||
|
db.delete(backupEntries).where(eq(backupEntries.id, hit.id)).run()
|
||||||
await rm(path.join(BACKUPS_DIR, hit.filename), { force: true })
|
await rm(path.join(BACKUPS_DIR, hit.filename), { force: true })
|
||||||
}
|
}
|
||||||
const next = rows.filter((r) => !deleteIds.has(r.id))
|
|
||||||
await writeBackupIndex(next)
|
|
||||||
return toDelete.length
|
return toDelete.length
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getBackupsDir(): string {
|
export function getBackupsDir(): string {
|
||||||
return BACKUPS_DIR
|
return BACKUPS_DIR
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getBackupIndexPath(): string {
|
|
||||||
return INDEX_PATH
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -18,8 +18,12 @@ services:
|
|||||||
image: git.shts.su/denozord/mikrotikmanager-frontend:latest
|
image: git.shts.su/denozord/mikrotikmanager-frontend:latest
|
||||||
container_name: mmapp-frontend
|
container_name: mmapp-frontend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
BACKEND_INTERNAL_URL: http://backend:8000
|
||||||
labels:
|
labels:
|
||||||
mmapp.updater.managed: "true"
|
mmapp.updater.managed: "true"
|
||||||
mmapp.updater.target: frontend
|
mmapp.updater.target: frontend
|
||||||
|
|||||||
@@ -321,7 +321,7 @@ wait_for_health() {
|
|||||||
while (( SECONDS < deadline )); do
|
while (( SECONDS < deadline )); do
|
||||||
if [[ "$health_type" == "http" ]]; then
|
if [[ "$health_type" == "http" ]]; then
|
||||||
local status
|
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"
|
last_status="$status"
|
||||||
if [[ "$status" == "$expect_status" ]]; then
|
if [[ "$status" == "$expect_status" ]]; then
|
||||||
log info "target=${target_id} action=health_ok status=${status}"
|
log info "target=${target_id} action=health_ok status=${status}"
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
"image": "git.shts.su/denozord/mikrotikmanager-frontend:latest",
|
"image": "git.shts.su/denozord/mikrotikmanager-frontend:latest",
|
||||||
"health": {
|
"health": {
|
||||||
"type": "http",
|
"type": "http",
|
||||||
"url": "http://frontend:3000/",
|
"url": "http://frontend:3000/dashboard",
|
||||||
"expect_status": 200
|
"expect_status": 200
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export function backendInternalUrl(): string {
|
||||||
|
return (process.env.BACKEND_INTERNAL_URL ?? "http://127.0.0.1:8000").replace(/\/$/, "")
|
||||||
|
}
|
||||||
+5
-2
@@ -91,9 +91,12 @@ export function DataSourceProvider({ children }: { children: React.ReactNode })
|
|||||||
}, [backendUrlLocked])
|
}, [backendUrlLocked])
|
||||||
|
|
||||||
const checkBackend = useCallback(async () => {
|
const checkBackend = useCallback(async () => {
|
||||||
const url = normalizeBackendUrl(backendUrl)
|
const healthUrl =
|
||||||
|
configuredBackendUrl().kind === "same-origin"
|
||||||
|
? "/health"
|
||||||
|
: `${normalizeBackendUrl(backendUrl)}/health`
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${url}/health`, { signal: AbortSignal.timeout(3000) })
|
const res = await fetch(healthUrl, { signal: AbortSignal.timeout(3000) })
|
||||||
setBackendStatus(res.ok)
|
setBackendStatus(res.ok)
|
||||||
} catch {
|
} catch {
|
||||||
setBackendStatus(false)
|
setBackendStatus(false)
|
||||||
|
|||||||
@@ -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";
|
import type { NextConfig } from "next";
|
||||||
|
import { backendInternalUrl } from "./lib/backend-internal-url";
|
||||||
const backendInternalUrl = (process.env.BACKEND_INTERNAL_URL ?? "http://127.0.0.1:8000").replace(
|
|
||||||
/\/$/,
|
|
||||||
"",
|
|
||||||
);
|
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
|
experimental: {
|
||||||
|
proxyClientMaxBodySize: "512mb",
|
||||||
|
},
|
||||||
async rewrites() {
|
async rewrites() {
|
||||||
|
const backendUrl = backendInternalUrl();
|
||||||
return [
|
return [
|
||||||
{ source: "/health", destination: `${backendInternalUrl}/health` },
|
{ source: "/health", destination: `${backendUrl}/health` },
|
||||||
{ source: "/api/:path*", destination: `${backendInternalUrl}/api/:path*` },
|
{ source: "/api/:path*", destination: `${backendUrl}/api/:path*` },
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { configuredBackendUrl } from "@/lib/backend-url"
|
||||||
|
|
||||||
export class ApiClientError extends Error {
|
export class ApiClientError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
message: string,
|
message: string,
|
||||||
@@ -13,13 +15,20 @@ function trimBaseUrl(baseUrl: string): string {
|
|||||||
return baseUrl.replace(/\/$/, "")
|
return baseUrl.replace(/\/$/, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveRequestUrl(baseUrl: string, path: string): string {
|
||||||
|
if (path.startsWith("/") && configuredBackendUrl().kind === "same-origin") {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
return trimBaseUrl(baseUrl) + path
|
||||||
|
}
|
||||||
|
|
||||||
export async function requestJson<T>(
|
export async function requestJson<T>(
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
path: string,
|
path: string,
|
||||||
init?: RequestInit,
|
init?: RequestInit,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const hasBody = init?.body != null
|
const hasBody = init?.body != null
|
||||||
const res = await fetch(trimBaseUrl(baseUrl) + path, {
|
const res = await fetch(resolveRequestUrl(baseUrl, path), {
|
||||||
...init,
|
...init,
|
||||||
headers: {
|
headers: {
|
||||||
...(hasBody ? { "Content-Type": "application/json" } : {}),
|
...(hasBody ? { "Content-Type": "application/json" } : {}),
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
import { ApiClientError } from "@/shared/api/http-client"
|
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 {
|
function trimBaseUrl(baseUrl: string): string {
|
||||||
return baseUrl.replace(/\/$/, "")
|
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 {
|
function parseFilename(contentDisposition: string | null, fallback: string): string {
|
||||||
if (!contentDisposition) return fallback
|
if (!contentDisposition) return fallback
|
||||||
const utfMatch = /filename\*=UTF-8''([^;]+)/i.exec(contentDisposition)
|
const utfMatch = /filename\*=UTF-8''([^;]+)/i.exec(contentDisposition)
|
||||||
@@ -22,7 +32,7 @@ function parseFilename(contentDisposition: string | null, fallback: string): str
|
|||||||
export async function downloadSystemDatabaseBackup(
|
export async function downloadSystemDatabaseBackup(
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
): Promise<{ blob: Blob; filename: 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) {
|
if (!res.ok) {
|
||||||
const payload = await res.json().catch(() => undefined)
|
const payload = await res.json().catch(() => undefined)
|
||||||
const msg =
|
const msg =
|
||||||
@@ -40,11 +50,16 @@ export async function downloadSystemDatabaseBackup(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function restoreSystemDatabaseBackup(baseUrl: string, file: File): Promise<void> {
|
export async function restoreSystemDatabaseBackup(baseUrl: string, file: File): Promise<void> {
|
||||||
const body = await file.arrayBuffer()
|
if (file.size > MAX_RESTORE_BYTES) {
|
||||||
const res = await fetch(`${trimBaseUrl(baseUrl)}/api/system/database/restore`, {
|
throw new ApiClientError(
|
||||||
|
`Файл больше ${MAX_RESTORE_BYTES / (1024 * 1024)} МБ — уменьшите бэкап или обратитесь к администратору`,
|
||||||
|
413,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const res = await fetch(resolveDatabaseApiUrl(baseUrl, "/api/system/database/restore"), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/octet-stream" },
|
headers: { "Content-Type": "application/octet-stream" },
|
||||||
body,
|
body: file,
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const payload = await res.json().catch(() => undefined)
|
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