Compare commits

...
3 Commits
Author SHA1 Message Date
Denozordec f69e65b014 fix(docker): добавить переменную окружения для внутреннего URL бэкенда
Docker images / prepare-release (push) Successful in 6s
Docker images / backend-image (push) Successful in 1m34s
Docker images / frontend-image (push) Successful in 1m38s
Docker images / updater-image (push) Successful in 37s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 6s
2026-05-12 22:21:57 +07:00
Denozordec 49d14a00af fix(data-collection): улучшить обработку ошибок и загрузку данных с API
Docker images / prepare-release (push) Successful in 5s
Docker images / backend-image (push) Successful in 1m32s
Docker images / frontend-image (push) Successful in 1m41s
Docker images / updater-image (push) Successful in 37s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 7s
2026-05-12 22:11:49 +07:00
Denozordec 9ab2418a5f feat(backup): добавить таблицу для резервных копий и функции для работы с ними
Docker images / prepare-release (push) Successful in 6s
Docker images / backend-image (push) Successful in 1m35s
Docker images / frontend-image (push) Successful in 1m42s
Docker images / updater-image (push) Successful in 37s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 6s
2026-05-12 21:56:13 +07:00
12 changed files with 316 additions and 142 deletions
+1
View File
@@ -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
+140 -67
View File
@@ -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,6 +58,27 @@ function makeApiFetch(backendUrl: string) {
} }
} }
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 }: { checked: boolean; onChange: (v: boolean) => void }) { function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return ( return (
<button <button
@@ -730,8 +751,8 @@ function RunRowDetail({ r }: { r: SchedulerRunRowDto }) {
} }
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 +807,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 +815,60 @@ 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))
}
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 {
@@ -950,7 +1003,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>
@@ -1226,51 +1290,60 @@ export default function DataCollectionPage() {
const uRet = Math.max(1, Number.parseInt(uptimeRetentionDraft, 10) || 14) const uRet = Math.max(1, Number.parseInt(uptimeRetentionDraft, 10) || 14)
const sApiInt = Math.max(10, Number.parseInt(serversApiIntervalDraft, 10) || 120) const sApiInt = Math.max(10, Number.parseInt(serversApiIntervalDraft, 10) || 120)
const ipInt = Math.max(30, Number.parseInt(internetPathIntervalDraft, 10) || 300) 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 certRenewInt = Math.max(300, Number.parseInt(certRenewIntervalDraft, 10) || 21600)
const renewBeforeDays = Math.max(1, Math.min(90, Number.parseInt(renewBeforeDaysDraft, 10) || 30)) const renewBeforeDays = Math.max(1, Math.min(90, Number.parseInt(renewBeforeDaysDraft, 10) || 30))
await apiFetch("/api/internet-path/settings", { const saveResults = await Promise.allSettled([
method: "PUT", apiFetch("/api/traffic/settings", {
body: JSON.stringify({ method: "PUT",
enabled: draftInternetPathEnabled, body: JSON.stringify({
intervalSec: ipInt, enabled: draftTrafficEnabled,
intervalSec: tInt,
retentionDays: tRet,
}),
}), }),
}) apiFetch("/api/servers-api-ping/settings", {
await apiFetch("/api/certificates/renew-settings", { method: "PUT",
method: "PUT", body: JSON.stringify({
body: JSON.stringify({ enabled: draftServersApiEnabled,
enabled: draftCertRenewEnabled, intervalSec: sApiInt,
intervalSec: certRenewInt, }),
renewBeforeDays,
}), }),
}) apiFetch("/api/uptime/settings", {
method: "PUT",
body: JSON.stringify({
resourcesEnabled: draftResourcesEnabled,
pingEnabled: draftPingEnabled,
speedEnabled: draftSpeedEnabled,
intervalSec: uRes,
probeIntervalSec: uPing,
speedIntervalSec: uSpd,
retentionDays: uRet,
}),
}),
apiFetch("/api/internet-path/settings", {
method: "PUT",
body: JSON.stringify({
enabled: draftInternetPathEnabled,
intervalSec: ipInt,
}),
}),
apiFetch("/api/certificates/renew-settings", {
method: "PUT",
body: JSON.stringify({
enabled: draftCertRenewEnabled,
intervalSec: certRenewInt,
renewBeforeDays,
}),
}),
])
const saveErrors = collectSettledErrors(
saveResults,
["трафик", "серверы REST API", "uptime", "internet path", "сертификаты"],
)
await loadCollectors() await loadCollectors()
if (saveErrors.length > 0) {
setCollectorError(`Не все настройки сохранились: ${saveErrors.join("; ")}`)
}
} catch (e) { } catch (e) {
setCollectorError(e instanceof Error ? e.message : "Не удалось сохранить") setCollectorError(e instanceof Error ? e.message : "Не удалось сохранить")
} finally { } finally {
+1
View File
@@ -4,3 +4,4 @@ dist/
*.db-shm *.db-shm
*.db-wal *.db-wal
.env .env
storage/backups/
+50
View File
@@ -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). */
+12
View File
@@ -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
+8 -17
View File
@@ -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)
} }
+76 -44
View File
@@ -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
}
+4
View File
@@ -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
+12 -5
View File
@@ -59,9 +59,13 @@ function normalizeBackendUrl(url: string): string {
} }
export function DataSourceProvider({ children }: { children: React.ReactNode }) { export function DataSourceProvider({ children }: { children: React.ReactNode }) {
const [mode, setModeState] = useState<DataSourceMode>(defaultDataSourceMode) const [mode, setModeState] = useState<DataSourceMode>(() =>
const [backendUrl, setBackendUrlState] = useState(LOCAL_DEFAULT_BACKEND_URL) typeof window === "undefined" ? defaultDataSourceMode() : readStoredMode(),
const [prefsHydrated, setPrefsHydrated] = useState(false) )
const [backendUrl, setBackendUrlState] = useState(() =>
typeof window === "undefined" ? LOCAL_DEFAULT_BACKEND_URL : readStoredBackendUrl(),
)
const [prefsHydrated, setPrefsHydrated] = useState(() => typeof window !== "undefined")
const [backendStatus, setBackendStatus] = useState<boolean | undefined>(undefined) const [backendStatus, setBackendStatus] = useState<boolean | undefined>(undefined)
const backendUrlLocked = isBackendUrlLocked() const backendUrlLocked = isBackendUrlLocked()
const mockModeAvailable = isMockDataSourceAvailable() const mockModeAvailable = isMockDataSourceAvailable()
@@ -91,9 +95,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)
+10 -1
View File
@@ -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 -1
View File
File diff suppressed because one or more lines are too long