Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
009011a917 | ||
|
|
399871f4f9 | ||
|
|
158fc36294 | ||
|
|
7dc4836c71 | ||
|
|
efc3812e12 | ||
|
|
8d5fd84962 | ||
|
|
f69e65b014 | ||
|
|
49d14a00af | ||
|
|
9ab2418a5f | ||
|
|
63aa9d424b |
@@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,201 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"id": "bec5c501-6d3f-4921-9774-5f0e3d526d87",
|
|
||||||
"serverId": "6",
|
|
||||||
"serverName": "ihor.msk.rt.shx.su",
|
|
||||||
"filename": "ihor.msk.rt.shx.su_2026-05-12_21-45-18.rsc",
|
|
||||||
"sizeBytes": 711251,
|
|
||||||
"createdAt": "2026-05-12T14:45:18.144Z",
|
|
||||||
"kind": "auto"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "2c1d9c24-b612-4756-8352-afd127b2fa81",
|
|
||||||
"serverId": "5",
|
|
||||||
"serverName": "servhost.nsk.rt.shx.su",
|
|
||||||
"filename": "servhost.nsk.rt.shx.su_2026-05-12_21-45-14.rsc",
|
|
||||||
"sizeBytes": 707990,
|
|
||||||
"createdAt": "2026-05-12T14:45:14.994Z",
|
|
||||||
"kind": "auto"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "3eac2f70-a5e8-428c-a50d-59223061827b",
|
|
||||||
"serverId": "4",
|
|
||||||
"serverName": "veesp.swe.rt.shx.su",
|
|
||||||
"filename": "veesp.swe.rt.shx.su_2026-05-12_21-45-08.rsc",
|
|
||||||
"sizeBytes": 2195,
|
|
||||||
"createdAt": "2026-05-12T14:45:08.312Z",
|
|
||||||
"kind": "auto"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "2c600052-70ff-4525-910d-725d0f4d2cfb",
|
|
||||||
"serverId": "3",
|
|
||||||
"serverName": "vpsville.msk.rt.shx.su",
|
|
||||||
"filename": "vpsville.msk.rt.shx.su_2026-05-12_21-45-07.rsc",
|
|
||||||
"sizeBytes": 710694,
|
|
||||||
"createdAt": "2026-05-12T14:45:07.786Z",
|
|
||||||
"kind": "auto"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "b97be1bb-9c72-44a8-be27-333ddb58ca24",
|
|
||||||
"serverId": "2",
|
|
||||||
"serverName": "Gateway",
|
|
||||||
"filename": "Gateway_2026-05-12_21-45-05.rsc",
|
|
||||||
"sizeBytes": 1712337,
|
|
||||||
"createdAt": "2026-05-12T14:45:05.250Z",
|
|
||||||
"kind": "auto"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "891217c8-faa2-40d7-b093-e325f41d8a3d",
|
|
||||||
"serverId": "6",
|
|
||||||
"serverName": "ihor.msk.rt.shx.su",
|
|
||||||
"filename": "ihor.msk.rt.shx.su_2026-05-12_21-44-21.rsc",
|
|
||||||
"sizeBytes": 711251,
|
|
||||||
"createdAt": "2026-05-12T14:44:21.828Z",
|
|
||||||
"kind": "auto"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "f6bc0abc-c783-4d6f-92b6-d0cc489e6772",
|
|
||||||
"serverId": "5",
|
|
||||||
"serverName": "servhost.nsk.rt.shx.su",
|
|
||||||
"filename": "servhost.nsk.rt.shx.su_2026-05-12_21-44-17.rsc",
|
|
||||||
"sizeBytes": 707990,
|
|
||||||
"createdAt": "2026-05-12T14:44:17.008Z",
|
|
||||||
"kind": "auto"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "3f8b71df-5879-4fda-98f9-316353db3657",
|
|
||||||
"serverId": "4",
|
|
||||||
"serverName": "veesp.swe.rt.shx.su",
|
|
||||||
"filename": "veesp.swe.rt.shx.su_2026-05-12_21-44-14.rsc",
|
|
||||||
"sizeBytes": 2195,
|
|
||||||
"createdAt": "2026-05-12T14:44:14.981Z",
|
|
||||||
"kind": "auto"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "0431c73a-a2a2-4100-8940-a7e560649f53",
|
|
||||||
"serverId": "3",
|
|
||||||
"serverName": "vpsville.msk.rt.shx.su",
|
|
||||||
"filename": "vpsville.msk.rt.shx.su_2026-05-12_21-44-14.rsc",
|
|
||||||
"sizeBytes": 710694,
|
|
||||||
"createdAt": "2026-05-12T14:44:14.434Z",
|
|
||||||
"kind": "auto"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "523315c7-4db9-4e09-8bfb-3f36dd692485",
|
|
||||||
"serverId": "2",
|
|
||||||
"serverName": "Gateway",
|
|
||||||
"filename": "Gateway_2026-05-12_21-44-11.rsc",
|
|
||||||
"sizeBytes": 1712337,
|
|
||||||
"createdAt": "2026-05-12T14:44:11.677Z",
|
|
||||||
"kind": "auto"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "b820bdc8-d184-4615-92e1-40df3872d554",
|
|
||||||
"serverId": "2",
|
|
||||||
"serverName": "Gateway",
|
|
||||||
"filename": "Gateway_2026-05-07_14-41-51.rsc",
|
|
||||||
"sizeBytes": 913056,
|
|
||||||
"createdAt": "2026-05-07T07:41:51.371Z",
|
|
||||||
"kind": "manual"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "7d46ee5b-c829-4d42-af90-847026bafd8a",
|
|
||||||
"serverId": "6",
|
|
||||||
"serverName": "ihor.msk.rt.shx.su",
|
|
||||||
"filename": "ihor.msk.rt.shx.su_2026-05-07_14-19-22.rsc",
|
|
||||||
"sizeBytes": 676415,
|
|
||||||
"createdAt": "2026-05-07T07:19:22.073Z",
|
|
||||||
"kind": "manual"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "c34f71e5-6766-456b-9b8a-9b8efaa91edc",
|
|
||||||
"serverId": "5",
|
|
||||||
"serverName": "servhost.nsk.rt.shx.su",
|
|
||||||
"filename": "servhost.nsk.rt.shx.su_2026-05-07_14-19-19.rsc",
|
|
||||||
"sizeBytes": 672268,
|
|
||||||
"createdAt": "2026-05-07T07:19:19.639Z",
|
|
||||||
"kind": "manual"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "40e253a1-98a8-40a7-982b-7c5af213f490",
|
|
||||||
"serverId": "4",
|
|
||||||
"serverName": "veesp.swe.rt.shx.su",
|
|
||||||
"filename": "veesp.swe.rt.shx.su_2026-05-07_14-19-17.rsc",
|
|
||||||
"sizeBytes": 2131,
|
|
||||||
"createdAt": "2026-05-07T07:19:17.801Z",
|
|
||||||
"kind": "manual"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "9199d0a8-9bc0-4d20-8498-b37f1f15262b",
|
|
||||||
"serverId": "3",
|
|
||||||
"serverName": "vpsville.msk.rt.shx.su",
|
|
||||||
"filename": "vpsville.msk.rt.shx.su_2026-05-07_14-19-17.rsc",
|
|
||||||
"sizeBytes": 675732,
|
|
||||||
"createdAt": "2026-05-07T07:19:17.272Z",
|
|
||||||
"kind": "manual"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "4de5263f-edb4-4ca9-ae90-2247defdc05e",
|
|
||||||
"serverId": "2",
|
|
||||||
"serverName": "Gateway",
|
|
||||||
"filename": "Gateway_2026-05-07_14-19-14.rsc",
|
|
||||||
"sizeBytes": 913056,
|
|
||||||
"createdAt": "2026-05-07T07:19:14.772Z",
|
|
||||||
"kind": "manual"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "de49d319-a24f-475c-bb6e-8075eff86380",
|
|
||||||
"serverId": "2",
|
|
||||||
"serverName": "Gateway",
|
|
||||||
"filename": "Gateway_2026-05-07_14-16-45.rsc",
|
|
||||||
"sizeBytes": 911521,
|
|
||||||
"createdAt": "2026-05-07T07:16:45.543Z",
|
|
||||||
"kind": "manual",
|
|
||||||
"notes": "async"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ec5b9e31-42cb-40b9-aba7-3501d5145713",
|
|
||||||
"serverId": "6",
|
|
||||||
"serverName": "ihor.msk.rt.shx.su",
|
|
||||||
"filename": "ihor.msk.rt.shx.su_2026-05-07_14-13-58.rsc",
|
|
||||||
"sizeBytes": 676415,
|
|
||||||
"createdAt": "2026-05-07T07:13:58.749Z",
|
|
||||||
"kind": "manual"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "7909c7de-a548-44d4-bdf7-7c221bfedd36",
|
|
||||||
"serverId": "5",
|
|
||||||
"serverName": "servhost.nsk.rt.shx.su",
|
|
||||||
"filename": "servhost.nsk.rt.shx.su_2026-05-07_14-13-56.rsc",
|
|
||||||
"sizeBytes": 672268,
|
|
||||||
"createdAt": "2026-05-07T07:13:56.245Z",
|
|
||||||
"kind": "manual"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "63339ebb-eb94-455e-a61b-368523fed7e1",
|
|
||||||
"serverId": "4",
|
|
||||||
"serverName": "veesp.swe.rt.shx.su",
|
|
||||||
"filename": "veesp.swe.rt.shx.su_2026-05-07_14-13-54.rsc",
|
|
||||||
"sizeBytes": 2131,
|
|
||||||
"createdAt": "2026-05-07T07:13:54.493Z",
|
|
||||||
"kind": "manual"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "daccab1d-f60a-4570-9d11-c7b06491f6f7",
|
|
||||||
"serverId": "3",
|
|
||||||
"serverName": "vpsville.msk.rt.shx.su",
|
|
||||||
"filename": "vpsville.msk.rt.shx.su_2026-05-07_14-13-53.rsc",
|
|
||||||
"sizeBytes": 675732,
|
|
||||||
"createdAt": "2026-05-07T07:13:53.791Z",
|
|
||||||
"kind": "manual"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "d976cae6-aae8-4f55-9452-71d5480ac8e8",
|
|
||||||
"serverId": "2",
|
|
||||||
"serverName": "Gateway",
|
|
||||||
"filename": "Gateway_2026-05-07_14-13-48.rsc",
|
|
||||||
"sizeBytes": 913056,
|
|
||||||
"createdAt": "2026-05-07T07:13:48.326Z",
|
|
||||||
"kind": "manual"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,44 +0,0 @@
|
|||||||
# synthetic export generated by MikrotikManager
|
|
||||||
# generated-at: 2026-05-07T07:13:21.880Z
|
|
||||||
|
|
||||||
/system identity
|
|
||||||
set name="veesp.swe.rt.shx.su"
|
|
||||||
|
|
||||||
/interface
|
|
||||||
:put "interface name="ether1" mtu=1500 disabled=no"
|
|
||||||
:put "interface name="MSK-DC" mtu=1350 disabled=no"
|
|
||||||
:put "interface name="MSK-IHOR" mtu=1400 disabled=no"
|
|
||||||
:put "interface name="MSK-VPSVILLE" mtu=1400 disabled=no"
|
|
||||||
:put "interface name="NSK-SERVHOST" mtu=1400 disabled=no"
|
|
||||||
:put "interface name="br1" mtu=1500 disabled=no"
|
|
||||||
:put "interface name="gre-tunnel1" mtu=1450 disabled=no"
|
|
||||||
:put "interface name="lo" mtu=65536 disabled=no"
|
|
||||||
:put "interface name="wg-tunnel" mtu=1420 disabled=no"
|
|
||||||
:put "interface name="wg1" mtu=1280 disabled=no"
|
|
||||||
|
|
||||||
/ip address
|
|
||||||
add address=62.182.194.146/24 interface="ether1"
|
|
||||||
add address=10.200.100.26/30 interface="MSK-IHOR"
|
|
||||||
add address=10.200.200.2/30 interface="*5"
|
|
||||||
add address=10.200.40.2/30 interface="MSK-VPSVILLE"
|
|
||||||
add address=10.205.1.2/30 interface="wg1"
|
|
||||||
add address=10.200.100.2/30 interface="*11" comment="Interface: MSK-SELECTEL (GRE) to selectel.msk.rt.shx.su"
|
|
||||||
add address=10.200.100.38/30 interface="MSK-DC" comment="Interface: MSK-DC (GRE) to dc.msk.rt.shx.su"
|
|
||||||
add address=10.200.100.14/30 interface="MSK-VPSVILLE" comment="Interface: MSK-VPSVILLE (GRE) to vpsville.msk.rt.shx.su"
|
|
||||||
add address=10.101.1.2/30 interface="gre-tunnel1"
|
|
||||||
add address=10.200.100.54/30 interface="NSK-SERVHOST" comment="Interface: NSK-SERVHOST (GRE) to servhost.nsk.rt.shx.su"
|
|
||||||
|
|
||||||
/ip route
|
|
||||||
add dst-address=0.0.0.0/0 gateway=62.182.194.1 distance=1
|
|
||||||
add dst-address=10.101.1.0/30 gateway=gre-tunnel1 distance=0
|
|
||||||
add dst-address=10.200.100.12/30 gateway=MSK-VPSVILLE distance=0
|
|
||||||
add dst-address=10.200.100.24/30 gateway=MSK-IHOR distance=0
|
|
||||||
add dst-address=10.200.100.36/30 gateway=MSK-DC distance=0
|
|
||||||
add dst-address=10.200.100.52/30 gateway=NSK-SERVHOST distance=0
|
|
||||||
add dst-address=10.205.1.0/30 gateway=wg1 distance=0
|
|
||||||
add dst-address=62.182.194.0/24 gateway=br1 distance=0
|
|
||||||
add dst-address=192.168.0.0/16 gateway=10.200.100.13%MSK-VPSVILLE distance=1
|
|
||||||
|
|
||||||
/ip firewall filter
|
|
||||||
add chain=input action=drop protocol=udp dst-port=53
|
|
||||||
add chain=input action=accept protocol=udp dst-port=13231
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
# synthetic export generated by MikrotikManager
|
|
||||||
# generated-at: 2026-05-07T07:13:54.118Z
|
|
||||||
|
|
||||||
/system identity
|
|
||||||
set name="veesp.swe.rt.shx.su"
|
|
||||||
|
|
||||||
/interface
|
|
||||||
:put "interface name="ether1" mtu=1500 disabled=no"
|
|
||||||
:put "interface name="MSK-DC" mtu=1350 disabled=no"
|
|
||||||
:put "interface name="MSK-IHOR" mtu=1400 disabled=no"
|
|
||||||
:put "interface name="MSK-VPSVILLE" mtu=1400 disabled=no"
|
|
||||||
:put "interface name="NSK-SERVHOST" mtu=1400 disabled=no"
|
|
||||||
:put "interface name="br1" mtu=1500 disabled=no"
|
|
||||||
:put "interface name="gre-tunnel1" mtu=1450 disabled=no"
|
|
||||||
:put "interface name="lo" mtu=65536 disabled=no"
|
|
||||||
:put "interface name="wg-tunnel" mtu=1420 disabled=no"
|
|
||||||
:put "interface name="wg1" mtu=1280 disabled=no"
|
|
||||||
|
|
||||||
/ip address
|
|
||||||
add address=62.182.194.146/24 interface="ether1"
|
|
||||||
add address=10.200.100.26/30 interface="MSK-IHOR"
|
|
||||||
add address=10.200.200.2/30 interface="*5"
|
|
||||||
add address=10.200.40.2/30 interface="MSK-VPSVILLE"
|
|
||||||
add address=10.205.1.2/30 interface="wg1"
|
|
||||||
add address=10.200.100.2/30 interface="*11" comment="Interface: MSK-SELECTEL (GRE) to selectel.msk.rt.shx.su"
|
|
||||||
add address=10.200.100.38/30 interface="MSK-DC" comment="Interface: MSK-DC (GRE) to dc.msk.rt.shx.su"
|
|
||||||
add address=10.200.100.14/30 interface="MSK-VPSVILLE" comment="Interface: MSK-VPSVILLE (GRE) to vpsville.msk.rt.shx.su"
|
|
||||||
add address=10.101.1.2/30 interface="gre-tunnel1"
|
|
||||||
add address=10.200.100.54/30 interface="NSK-SERVHOST" comment="Interface: NSK-SERVHOST (GRE) to servhost.nsk.rt.shx.su"
|
|
||||||
|
|
||||||
/ip route
|
|
||||||
add dst-address=0.0.0.0/0 gateway=62.182.194.1 distance=1
|
|
||||||
add dst-address=10.101.1.0/30 gateway=gre-tunnel1 distance=0
|
|
||||||
add dst-address=10.200.100.12/30 gateway=MSK-VPSVILLE distance=0
|
|
||||||
add dst-address=10.200.100.24/30 gateway=MSK-IHOR distance=0
|
|
||||||
add dst-address=10.200.100.36/30 gateway=MSK-DC distance=0
|
|
||||||
add dst-address=10.200.100.52/30 gateway=NSK-SERVHOST distance=0
|
|
||||||
add dst-address=10.205.1.0/30 gateway=wg1 distance=0
|
|
||||||
add dst-address=62.182.194.0/24 gateway=br1 distance=0
|
|
||||||
add dst-address=192.168.0.0/16 gateway=10.200.100.13%MSK-VPSVILLE distance=1
|
|
||||||
|
|
||||||
/ip firewall filter
|
|
||||||
add chain=input action=drop protocol=udp dst-port=53
|
|
||||||
add chain=input action=accept protocol=udp dst-port=13231
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
# synthetic export generated by MikrotikManager
|
|
||||||
# generated-at: 2026-05-07T07:19:17.424Z
|
|
||||||
|
|
||||||
/system identity
|
|
||||||
set name="veesp.swe.rt.shx.su"
|
|
||||||
|
|
||||||
/interface
|
|
||||||
:put "interface name="ether1" mtu=1500 disabled=no"
|
|
||||||
:put "interface name="MSK-DC" mtu=1350 disabled=no"
|
|
||||||
:put "interface name="MSK-IHOR" mtu=1400 disabled=no"
|
|
||||||
:put "interface name="MSK-VPSVILLE" mtu=1400 disabled=no"
|
|
||||||
:put "interface name="NSK-SERVHOST" mtu=1400 disabled=no"
|
|
||||||
:put "interface name="br1" mtu=1500 disabled=no"
|
|
||||||
:put "interface name="gre-tunnel1" mtu=1450 disabled=no"
|
|
||||||
:put "interface name="lo" mtu=65536 disabled=no"
|
|
||||||
:put "interface name="wg-tunnel" mtu=1420 disabled=no"
|
|
||||||
:put "interface name="wg1" mtu=1280 disabled=no"
|
|
||||||
|
|
||||||
/ip address
|
|
||||||
add address=62.182.194.146/24 interface="ether1"
|
|
||||||
add address=10.200.100.26/30 interface="MSK-IHOR"
|
|
||||||
add address=10.200.200.2/30 interface="*5"
|
|
||||||
add address=10.200.40.2/30 interface="MSK-VPSVILLE"
|
|
||||||
add address=10.205.1.2/30 interface="wg1"
|
|
||||||
add address=10.200.100.2/30 interface="*11" comment="Interface: MSK-SELECTEL (GRE) to selectel.msk.rt.shx.su"
|
|
||||||
add address=10.200.100.38/30 interface="MSK-DC" comment="Interface: MSK-DC (GRE) to dc.msk.rt.shx.su"
|
|
||||||
add address=10.200.100.14/30 interface="MSK-VPSVILLE" comment="Interface: MSK-VPSVILLE (GRE) to vpsville.msk.rt.shx.su"
|
|
||||||
add address=10.101.1.2/30 interface="gre-tunnel1"
|
|
||||||
add address=10.200.100.54/30 interface="NSK-SERVHOST" comment="Interface: NSK-SERVHOST (GRE) to servhost.nsk.rt.shx.su"
|
|
||||||
|
|
||||||
/ip route
|
|
||||||
add dst-address=0.0.0.0/0 gateway=62.182.194.1 distance=1
|
|
||||||
add dst-address=10.101.1.0/30 gateway=gre-tunnel1 distance=0
|
|
||||||
add dst-address=10.200.100.12/30 gateway=MSK-VPSVILLE distance=0
|
|
||||||
add dst-address=10.200.100.24/30 gateway=MSK-IHOR distance=0
|
|
||||||
add dst-address=10.200.100.36/30 gateway=MSK-DC distance=0
|
|
||||||
add dst-address=10.200.100.52/30 gateway=NSK-SERVHOST distance=0
|
|
||||||
add dst-address=10.205.1.0/30 gateway=wg1 distance=0
|
|
||||||
add dst-address=62.182.194.0/24 gateway=br1 distance=0
|
|
||||||
add dst-address=192.168.0.0/16 gateway=10.200.100.13%MSK-VPSVILLE distance=1
|
|
||||||
|
|
||||||
/ip firewall filter
|
|
||||||
add chain=input action=drop protocol=udp dst-port=53
|
|
||||||
add chain=input action=accept protocol=udp dst-port=13231
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
# synthetic export generated by MikrotikManager
|
|
||||||
# generated-at: 2026-05-12T14:44:14.588Z
|
|
||||||
|
|
||||||
/system identity
|
|
||||||
set name="veesp.swe.rt.shx.su"
|
|
||||||
|
|
||||||
/interface
|
|
||||||
:put "interface name="ether1" mtu=1500 disabled=no"
|
|
||||||
:put "interface name="MSK-DC" mtu=1350 disabled=no"
|
|
||||||
:put "interface name="MSK-IHOR" mtu=1400 disabled=no"
|
|
||||||
:put "interface name="MSK-VPSVILLE" mtu=1400 disabled=no"
|
|
||||||
:put "interface name="NSK-SERVHOST" mtu=1400 disabled=no"
|
|
||||||
:put "interface name="br1" mtu=1500 disabled=no"
|
|
||||||
:put "interface name="gre-tunnel1" mtu=1450 disabled=no"
|
|
||||||
:put "interface name="lo" mtu=65536 disabled=no"
|
|
||||||
:put "interface name="wg-tunnel" mtu=1420 disabled=no"
|
|
||||||
:put "interface name="wg1" mtu=1280 disabled=no"
|
|
||||||
|
|
||||||
/ip address
|
|
||||||
add address=62.182.194.146/24 interface="ether1"
|
|
||||||
add address=10.200.100.26/30 interface="MSK-IHOR"
|
|
||||||
add address=10.200.200.2/30 interface="*5"
|
|
||||||
add address=10.200.40.2/30 interface="MSK-VPSVILLE"
|
|
||||||
add address=10.205.1.2/30 interface="wg1"
|
|
||||||
add address=10.200.100.2/30 interface="*11" comment="Interface: MSK-SELECTEL (GRE) to selectel.msk.rt.shx.su"
|
|
||||||
add address=10.200.100.38/30 interface="MSK-DC" comment="Interface: MSK-DC (GRE) to dc.msk.rt.shx.su"
|
|
||||||
add address=10.200.100.14/30 interface="MSK-VPSVILLE" comment="Interface: MSK-VPSVILLE (GRE) to vpsville.msk.rt.shx.su"
|
|
||||||
add address=10.101.1.2/30 interface="gre-tunnel1"
|
|
||||||
add address=10.200.100.54/30 interface="NSK-SERVHOST" comment="Interface: NSK-SERVHOST (GRE) to servhost.nsk.rt.shx.su"
|
|
||||||
|
|
||||||
/ip route
|
|
||||||
add dst-address=192.168.0.0/16 gateway=10.200.100.13%MSK-VPSVILLE distance=1
|
|
||||||
add dst-address=0.0.0.0/0 gateway=62.182.194.1 distance=1
|
|
||||||
add dst-address=10.101.1.0/30 gateway=gre-tunnel1 distance=0
|
|
||||||
add dst-address=10.200.100.12/30 gateway=MSK-VPSVILLE distance=0
|
|
||||||
add dst-address=10.200.100.24/30 gateway=MSK-IHOR distance=0
|
|
||||||
add dst-address=10.200.100.36/30 gateway=MSK-DC distance=0
|
|
||||||
add dst-address=10.200.100.52/30 gateway=NSK-SERVHOST distance=0
|
|
||||||
add dst-address=10.205.1.0/30 gateway=wg1 distance=0
|
|
||||||
add dst-address=62.182.194.0/24 gateway=br1 distance=0
|
|
||||||
add dst-address=192.168.0.0/16 gateway=10.200.100.53 distance=1
|
|
||||||
|
|
||||||
/ip firewall filter
|
|
||||||
add chain=input action=drop protocol=udp dst-port=53
|
|
||||||
add chain=input action=accept protocol=udp dst-port=13231
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
# synthetic export generated by MikrotikManager
|
|
||||||
# generated-at: 2026-05-12T14:45:07.934Z
|
|
||||||
|
|
||||||
/system identity
|
|
||||||
set name="veesp.swe.rt.shx.su"
|
|
||||||
|
|
||||||
/interface
|
|
||||||
:put "interface name="ether1" mtu=1500 disabled=no"
|
|
||||||
:put "interface name="MSK-DC" mtu=1350 disabled=no"
|
|
||||||
:put "interface name="MSK-IHOR" mtu=1400 disabled=no"
|
|
||||||
:put "interface name="MSK-VPSVILLE" mtu=1400 disabled=no"
|
|
||||||
:put "interface name="NSK-SERVHOST" mtu=1400 disabled=no"
|
|
||||||
:put "interface name="br1" mtu=1500 disabled=no"
|
|
||||||
:put "interface name="gre-tunnel1" mtu=1450 disabled=no"
|
|
||||||
:put "interface name="lo" mtu=65536 disabled=no"
|
|
||||||
:put "interface name="wg-tunnel" mtu=1420 disabled=no"
|
|
||||||
:put "interface name="wg1" mtu=1280 disabled=no"
|
|
||||||
|
|
||||||
/ip address
|
|
||||||
add address=62.182.194.146/24 interface="ether1"
|
|
||||||
add address=10.200.100.26/30 interface="MSK-IHOR"
|
|
||||||
add address=10.200.200.2/30 interface="*5"
|
|
||||||
add address=10.200.40.2/30 interface="MSK-VPSVILLE"
|
|
||||||
add address=10.205.1.2/30 interface="wg1"
|
|
||||||
add address=10.200.100.2/30 interface="*11" comment="Interface: MSK-SELECTEL (GRE) to selectel.msk.rt.shx.su"
|
|
||||||
add address=10.200.100.38/30 interface="MSK-DC" comment="Interface: MSK-DC (GRE) to dc.msk.rt.shx.su"
|
|
||||||
add address=10.200.100.14/30 interface="MSK-VPSVILLE" comment="Interface: MSK-VPSVILLE (GRE) to vpsville.msk.rt.shx.su"
|
|
||||||
add address=10.101.1.2/30 interface="gre-tunnel1"
|
|
||||||
add address=10.200.100.54/30 interface="NSK-SERVHOST" comment="Interface: NSK-SERVHOST (GRE) to servhost.nsk.rt.shx.su"
|
|
||||||
|
|
||||||
/ip route
|
|
||||||
add dst-address=192.168.0.0/16 gateway=10.200.100.13%MSK-VPSVILLE distance=1
|
|
||||||
add dst-address=0.0.0.0/0 gateway=62.182.194.1 distance=1
|
|
||||||
add dst-address=10.101.1.0/30 gateway=gre-tunnel1 distance=0
|
|
||||||
add dst-address=10.200.100.12/30 gateway=MSK-VPSVILLE distance=0
|
|
||||||
add dst-address=10.200.100.24/30 gateway=MSK-IHOR distance=0
|
|
||||||
add dst-address=10.200.100.36/30 gateway=MSK-DC distance=0
|
|
||||||
add dst-address=10.200.100.52/30 gateway=NSK-SERVHOST distance=0
|
|
||||||
add dst-address=10.205.1.0/30 gateway=wg1 distance=0
|
|
||||||
add dst-address=62.182.194.0/24 gateway=br1 distance=0
|
|
||||||
add dst-address=192.168.0.0/16 gateway=10.200.100.53 distance=1
|
|
||||||
|
|
||||||
/ip firewall filter
|
|
||||||
add chain=input action=drop protocol=udp dst-port=53
|
|
||||||
add chain=input action=accept protocol=udp dst-port=13231
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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