Compare commits

..
5 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
Denozordec 63aa9d424b chore(backups): удалить устаревшие резервные копии Gateway
Docker images / prepare-release (push) Successful in 7s
Docker images / backend-image (push) Successful in 1m32s
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 21:55:41 +07:00
Denozordec 43ab17b253 feat(scheduler): добавить расписание бэкапов и автообновление сертификатов
Docker images / prepare-release (push) Successful in 6s
Docker images / backend-image (push) Successful in 2m13s
Docker images / frontend-image (push) Successful in 2m11s
Docker images / updater-image (push) Successful in 50s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 8s
2026-05-12 21:46:11 +07:00
45 changed files with 1495 additions and 157128 deletions
+1
View File
@@ -40,6 +40,7 @@ ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV HOSTNAME=0.0.0.0
ENV PORT=3000
ENV BACKEND_INTERNAL_URL=http://backend:8000
COPY --from=build /app/public ./public
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
+42 -5
View File
@@ -20,7 +20,7 @@ import { cn } from "@/lib/utils"
import { useDataSource } from "@/lib/data-source"
import { listServers } from "@/shared/api/servers"
import { toFrontendServer } from "@/entities/server/model/mappers"
import { createBackupsAsync, deleteBackup, getBackupJob, listBackups, type BackupItem } from "@/shared/api/backups"
import { createBackupsAsync, deleteBackup, getBackupJob, getBackupScheduleSettings, listBackups, putBackupScheduleSettings, type BackupItem } from "@/shared/api/backups"
import { toast } from "sonner"
// ─── small UI helpers ─────────────────────────────────────────────────────────
@@ -116,6 +116,7 @@ export default function BackupsPage() {
const [opBusy, setOpBusy] = useState(false)
const [opError, setOpError] = useState<string | null>(null)
const [backupJobId, setBackupJobId] = useState<string | null>(null)
const [scheduleSaveBusy, setScheduleSaveBusy] = useState(false)
// Schedule
const [schedule, setSchedule] = useState(defaultSchedule)
@@ -140,7 +141,29 @@ export default function BackupsPage() {
}
function handleSave() {
toast.success("Настройки сохранены")
if (scheduleSaveBusy) return
setScheduleSaveBusy(true)
setOpError(null)
void (async () => {
try {
await putBackupScheduleSettings(backendUrl, {
enabled: schedule.enabled,
frequency: schedule.frequency,
hour: schedule.hour,
minute: schedule.minute,
weekDay: schedule.weekDay,
monthDay: schedule.monthDay,
keepCount: schedule.keepCount,
format: schedule.format,
serverIds: [...selectedServers],
})
toast.success("Настройки сохранены")
} catch (e) {
setOpError(e instanceof Error ? e.message : "Ошибка сохранения расписания")
} finally {
setScheduleSaveBusy(false)
}
})()
}
// Manual backup sheet
@@ -171,14 +194,28 @@ export default function BackupsPage() {
setLoading(true)
setOpError(null)
try {
const [serversRows, backupsRows] = await Promise.all([
const [serversRows, backupsRows, scheduleRow] = await Promise.all([
listServers(backendUrl),
listBackups(backendUrl),
getBackupScheduleSettings(backendUrl),
])
const mappedServers = serversRows.map((s) => toFrontendServer(s))
setLiveServers(mappedServers)
setBackupList(backupsRows.map(mapApiBackupToUi))
setSelectedServers(new Set(mappedServers.map((s) => s.id)))
setSchedule({
enabled: scheduleRow.enabled,
frequency: scheduleRow.frequency,
hour: scheduleRow.hour,
minute: scheduleRow.minute,
weekDay: scheduleRow.weekDay,
monthDay: scheduleRow.monthDay,
keepCount: scheduleRow.keepCount,
format: scheduleRow.format,
})
const serverIds = scheduleRow.serverIds.length > 0
? scheduleRow.serverIds
: mappedServers.map((s) => s.id)
setSelectedServers(new Set(serverIds.filter((id) => mappedServers.some((s) => s.id === id))))
} catch (e) {
setOpError(e instanceof Error ? e.message : "Ошибка загрузки данных")
} finally {
@@ -711,7 +748,7 @@ export default function BackupsPage() {
{/* Save button */}
<div className="lg:col-span-2 flex items-center gap-3">
<Button onClick={handleSave} className="gap-2">
<Button onClick={handleSave} className="gap-2" disabled={scheduleSaveBusy}>
Сохранить настройки
</Button>
</div>
+238 -59
View File
@@ -24,13 +24,15 @@ import {
type SchedulerRunRowDto,
type UptimeSettingsDto,
} from "@/lib/scheduler-settings"
import { requestJson } from "@/shared/api/http-client"
import { requestJson, ApiClientError } from "@/shared/api/http-client"
import {
parseSchedulerRunSnapshot,
type AlertEngineRuleDiagSnapshot,
type AlertEngineRunSnapshot,
type GreBgpSnapshotRunSnapshot,
type InternetPathRunSnapshot,
type CertificatesRenewRunSnapshot,
type BackupsRunSnapshot,
type PingRunSnapshot,
type ResourcesRunSnapshot,
type SchedulerRunSnapshot,
@@ -56,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 }) {
return (
<button
@@ -464,6 +487,73 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
</div>
)
}
if (snap.job === "certificates_renew") {
const c = snap as CertificatesRenewRunSnapshot
return (
<div className="space-y-3">
{c.skipped ? (
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущая проверка ещё выполнялась или задача отключена.</p>
) : null}
<dl className="grid grid-cols-2 gap-3 text-xs sm:grid-cols-4">
<div>
<dt className="text-muted-foreground">Проверено</dt>
<dd className="font-mono font-medium">{c.checked}</dd>
</div>
<div>
<dt className="text-muted-foreground">Обновлено</dt>
<dd className="font-mono font-medium">{c.renewed}</dd>
</div>
<div>
<dt className="text-muted-foreground">Пропущено</dt>
<dd className="font-mono font-medium">{c.skippedTargets}</dd>
</div>
<div>
<dt className="text-muted-foreground">Ошибки</dt>
<dd className="font-mono font-medium">{c.errors.length}</dd>
</div>
</dl>
{c.errors.length ? (
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 space-y-1">
{c.errors.map((e, i) => (
<p key={i} className="break-words">{e}</p>
))}
</div>
) : null}
</div>
)
}
if (snap.job === "backups") {
const b = snap as BackupsRunSnapshot
return (
<div className="space-y-3">
<dl className="grid grid-cols-2 gap-3 text-xs sm:grid-cols-4">
<div>
<dt className="text-muted-foreground">Слот расписания</dt>
<dd className="font-mono font-medium">{b.due ? "да" : "нет"}</dd>
</div>
<div>
<dt className="text-muted-foreground">Создано</dt>
<dd className="font-mono font-medium">{b.created}</dd>
</div>
<div>
<dt className="text-muted-foreground">Ошибки</dt>
<dd className="font-mono font-medium">{b.failures}</dd>
</div>
<div>
<dt className="text-muted-foreground">Удалено старых</dt>
<dd className="font-mono font-medium">{b.pruned}</dd>
</div>
</dl>
{b.errors?.length ? (
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 space-y-1">
{b.errors.map((e, i) => (
<p key={i} className="break-words">{e}</p>
))}
</div>
) : null}
</div>
)
}
if (snap.job === "alert_engine") {
const a = snap as AlertEngineRunSnapshot
const transitionRu = (t: AlertEngineRuleDiagSnapshot["hitTransition"]) => {
@@ -661,8 +751,8 @@ function RunRowDetail({ r }: { r: SchedulerRunRowDto }) {
}
export default function DataCollectionPage() {
const { mode, backendUrl } = useDataSource()
const isLive = mode === "live"
const { mode, backendUrl, prefsHydrated } = useDataSource()
const isLive = prefsHydrated && mode === "live"
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
const [trafficCollector, setTrafficCollector] = useState<CollectorSettingsDto | null>(null)
@@ -683,6 +773,9 @@ export default function DataCollectionPage() {
const [draftSpeedEnabled, setDraftSpeedEnabled] = useState(true)
const [draftInternetPathEnabled, setDraftInternetPathEnabled] = useState(true)
const [internetPathIntervalDraft, setInternetPathIntervalDraft] = useState("300")
const [draftCertRenewEnabled, setDraftCertRenewEnabled] = useState(true)
const [certRenewIntervalDraft, setCertRenewIntervalDraft] = useState("21600")
const [renewBeforeDaysDraft, setRenewBeforeDaysDraft] = useState("30")
const [schedulerRuns, setSchedulerRuns] = useState<SchedulerRunRowDto[]>([])
const [runFilterJobKey, setRunFilterJobKey] = useState<string>("")
const [runNowJobKey, setRunNowJobKey] = useState<string | null>(null)
@@ -714,32 +807,68 @@ export default function DataCollectionPage() {
runFilterJobKey && SCHEDULER_JOB_KEYS.includes(runFilterJobKey as (typeof SCHEDULER_JOB_KEYS)[number])
? `?limit=80&jobKey=${encodeURIComponent(runFilterJobKey)}`
: "?limit=80"
const [traffic, serversApi, uptime, internetPath, runsRes] = await Promise.all([
const [trafficRes, serversApiRes, uptimeRes, internetPathRes, certRenewRes, runsRes] = await Promise.allSettled([
apiFetch<CollectorSettingsDto>("/api/traffic/settings"),
apiFetch<CollectorSettingsDto>("/api/servers-api-ping/settings"),
apiFetch<UptimeSettingsDto>("/api/uptime/settings"),
apiFetch<CollectorSettingsDto>("/api/internet-path/settings"),
apiFetch<{ enabled: boolean; intervalSec: number; renewBeforeDays: number }>("/api/certificates/renew-settings"),
apiFetch<{ runs: SchedulerRunRowDto[] }>(`/api/scheduler/runs${runsQuery}`),
])
setTrafficCollector(traffic)
setServersApiCollector(serversApi)
setUptimeCollector(uptime)
setInternetPathCollector(internetPath)
setSchedulerRuns(runsRes.runs ?? [])
setTrafficIntervalDraft(String(traffic.intervalSec))
setTrafficRetentionDraft(String(traffic.retentionDays))
setUptimeResourceIntervalDraft(String(uptime.intervalSec ?? 300))
setUptimeIntervalDraft(String(uptime.probeIntervalSec ?? 15))
setUptimeSpeedIntervalDraft(String(uptime.speedIntervalSec ?? 60))
setUptimeRetentionDraft(String(uptime.retentionDays))
setDraftTrafficEnabled(!!traffic.enabled)
setDraftServersApiEnabled(!!serversApi.enabled)
setServersApiIntervalDraft(String(serversApi.intervalSec ?? 120))
setDraftResourcesEnabled(!!(uptime.resourcesEnabled ?? uptime.enabled))
setDraftPingEnabled(!!(uptime.pingEnabled ?? uptime.enabled))
setDraftSpeedEnabled(!!(uptime.speedEnabled ?? uptime.enabled))
setDraftInternetPathEnabled(!!internetPath.enabled)
setInternetPathIntervalDraft(String(internetPath.intervalSec ?? 300))
const loadErrors = collectSettledErrors(
[trafficRes, serversApiRes, uptimeRes, internetPathRes, certRenewRes, runsRes],
["трафик", "серверы REST API", "uptime", "internet path", "сертификаты", "журнал планировщика"],
)
if (loadErrors.length > 0) {
setCollectorError(loadErrors.join("; "))
}
const traffic = readSettled(trafficRes)
if (traffic) {
setTrafficCollector(traffic)
setTrafficIntervalDraft(String(traffic.intervalSec))
setTrafficRetentionDraft(String(traffic.retentionDays))
setDraftTrafficEnabled(!!traffic.enabled)
}
const serversApi = readSettled(serversApiRes)
if (serversApi) {
setServersApiCollector(serversApi)
setDraftServersApiEnabled(!!serversApi.enabled)
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) {
setCollectorError(e instanceof Error ? e.message : "Не удалось загрузить данные")
} finally {
@@ -781,8 +910,10 @@ export default function DataCollectionPage() {
if (draftPingEnabled) n += 1
if (draftSpeedEnabled) n += 1
if (draftInternetPathEnabled) n += 1
if (draftCertRenewEnabled) n += 1
return n
}, [
draftCertRenewEnabled,
draftInternetPathEnabled,
draftPingEnabled,
draftResourcesEnabled,
@@ -872,7 +1003,18 @@ export default function DataCollectionPage() {
<div className="flex-1 overflow-y-auto p-6">
<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>
<CardHeader>
<CardTitle className="text-base">Нужен live-режим</CardTitle>
@@ -936,7 +1078,7 @@ export default function DataCollectionPage() {
<tbody className="divide-y divide-border">
{SCHEDULER_JOB_KEYS.map((jobKey) => {
const j = schedulerJobsByKey[jobKey]
const fixedSchedule = jobKey === "gre_bgp" || jobKey === "alert_engine"
const fixedSchedule = jobKey === "gre_bgp" || jobKey === "alert_engine" || jobKey === "backups"
const en = fixedSchedule
? Boolean(j?.enabled ?? true)
: jobKey === "traffic"
@@ -949,7 +1091,9 @@ export default function DataCollectionPage() {
? draftPingEnabled
: jobKey === "uptime_speed"
? draftSpeedEnabled
: draftInternetPathEnabled
: jobKey === "certificates_renew"
? draftCertRenewEnabled
: draftInternetPathEnabled
const iv = fixedSchedule
? String(j?.intervalSec ?? (jobKey === "gre_bgp" ? 30 : 20))
: jobKey === "traffic"
@@ -962,7 +1106,9 @@ export default function DataCollectionPage() {
? uptimeIntervalDraft
: jobKey === "uptime_speed"
? uptimeSpeedIntervalDraft
: internetPathIntervalDraft
: jobKey === "certificates_renew"
? certRenewIntervalDraft
: internetPathIntervalDraft
const setIv = fixedSchedule
? () => {}
: jobKey === "traffic"
@@ -975,7 +1121,9 @@ export default function DataCollectionPage() {
? setUptimeIntervalDraft
: jobKey === "uptime_speed"
? setUptimeSpeedIntervalDraft
: setInternetPathIntervalDraft
: jobKey === "certificates_renew"
? setCertRenewIntervalDraft
: setInternetPathIntervalDraft
const defSec = fixedSchedule
? Number(j?.intervalSec ?? (jobKey === "gre_bgp" ? 30 : 20))
: jobKey === "traffic"
@@ -988,7 +1136,9 @@ export default function DataCollectionPage() {
? 15
: jobKey === "uptime_speed"
? 60
: 300
: jobKey === "certificates_renew"
? 21600
: 300
return (
<tr key={jobKey} className="hover:bg-muted/40">
<td className="px-5 py-3 align-top">
@@ -1009,6 +1159,7 @@ export default function DataCollectionPage() {
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)
}}
/>
@@ -1111,6 +1262,15 @@ export default function DataCollectionPage() {
inputMode="numeric"
/>
</div>
<div>
<p className="text-xs text-muted-foreground mb-1.5">Обновлять сертификаты за (дней)</p>
<Input
value={renewBeforeDaysDraft}
onChange={(e) => setRenewBeforeDaysDraft(e.target.value)}
className="h-8 text-sm"
inputMode="numeric"
/>
</div>
</div>
<p className="text-[11px] text-muted-foreground leading-snug">
Для ping-проб у отдельных записей в мониторинге можно задать свой интервал (0 = глобальный «Интервал ping» в таблице).
@@ -1130,41 +1290,60 @@ export default function DataCollectionPage() {
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,
const certRenewInt = Math.max(300, Number.parseInt(certRenewIntervalDraft, 10) || 21600)
const renewBeforeDays = Math.max(1, Math.min(90, Number.parseInt(renewBeforeDaysDraft, 10) || 30))
const saveResults = await Promise.allSettled([
apiFetch("/api/traffic/settings", {
method: "PUT",
body: JSON.stringify({
enabled: draftTrafficEnabled,
intervalSec: tInt,
retentionDays: tRet,
}),
}),
})
await apiFetch("/api/servers-api-ping/settings", {
method: "PUT",
body: JSON.stringify({
enabled: draftServersApiEnabled,
intervalSec: sApiInt,
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,
apiFetch("/api/uptime/settings", {
method: "PUT",
body: JSON.stringify({
resourcesEnabled: draftResourcesEnabled,
pingEnabled: draftPingEnabled,
speedEnabled: draftSpeedEnabled,
intervalSec: uRes,
probeIntervalSec: uPing,
speedIntervalSec: uSpd,
retentionDays: uRet,
}),
}),
})
await apiFetch("/api/internet-path/settings", {
method: "PUT",
body: JSON.stringify({
enabled: draftInternetPathEnabled,
intervalSec: ipInt,
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()
if (saveErrors.length > 0) {
setCollectorError(`Не все настройки сохранились: ${saveErrors.join("; ")}`)
}
} catch (e) {
setCollectorError(e instanceof Error ? e.message : "Не удалось сохранить")
} finally {
+1
View File
@@ -4,3 +4,4 @@ dist/
*.db-shm
*.db-wal
.env
storage/backups/
+96
View File
@@ -1,4 +1,6 @@
import Database from "better-sqlite3"
import { existsSync, readFileSync } from "node:fs"
import path from "node:path"
type SqliteHandle = InstanceType<typeof Database>
import { drizzle } from "drizzle-orm/better-sqlite3"
@@ -351,6 +353,7 @@ CREATE TABLE IF NOT EXISTS certificate_issue_jobs (
id TEXT PRIMARY KEY,
status TEXT NOT NULL DEFAULT 'queued',
step TEXT NOT NULL DEFAULT 'queued',
source TEXT NOT NULL DEFAULT 'manual',
server_id TEXT NOT NULL,
cert_name TEXT NOT NULL,
domain_names TEXT NOT NULL,
@@ -362,6 +365,47 @@ CREATE TABLE IF NOT EXISTS certificate_issue_jobs (
error TEXT
);
CREATE TABLE IF NOT EXISTS certificate_renew_settings (
id INTEGER PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 1,
interval_sec INTEGER NOT NULL DEFAULT 21600,
renew_before_days INTEGER NOT NULL DEFAULT 30,
last_collected_at TEXT,
last_duration_ms INTEGER,
last_error TEXT,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS backup_schedule_settings (
id INTEGER PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 1,
frequency TEXT NOT NULL DEFAULT 'daily',
hour INTEGER NOT NULL DEFAULT 3,
minute INTEGER NOT NULL DEFAULT 0,
week_day INTEGER NOT NULL DEFAULT 0,
month_day INTEGER NOT NULL DEFAULT 1,
keep_count INTEGER NOT NULL DEFAULT 7,
format TEXT NOT NULL DEFAULT 'rsc',
server_ids_json TEXT NOT NULL DEFAULT '[]',
last_run_at TEXT,
last_duration_ms INTEGER,
last_error TEXT,
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 (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
@@ -617,12 +661,64 @@ SELECT 1, 'https://acme-v02.api.letsencrypt.org/directory', '', '', ''
WHERE NOT EXISTS (SELECT 1 FROM acme_settings WHERE id = 1);
`)
const certIssueJobCols = sqlite.prepare(`PRAGMA table_info('certificate_issue_jobs')`).all() as Array<{ name?: string }>
if (!certIssueJobCols.some((c) => c.name === "source")) {
sqlite.exec(`ALTER TABLE certificate_issue_jobs ADD COLUMN source TEXT NOT NULL DEFAULT 'manual'`)
}
sqlite.exec(`
INSERT INTO certificate_renew_settings (id, enabled, interval_sec, renew_before_days)
SELECT 1, 1, 21600, 30
WHERE NOT EXISTS (SELECT 1 FROM certificate_renew_settings WHERE id = 1);
`)
sqlite.exec(`
INSERT INTO backup_schedule_settings (id, enabled, frequency, hour, minute, week_day, month_day, keep_count, format, server_ids_json)
SELECT 1, 1, 'daily', 3, 0, 0, 1, 7, 'rsc', '[]'
WHERE NOT EXISTS (SELECT 1 FROM backup_schedule_settings WHERE id = 1);
`)
sqlite.exec(`
INSERT INTO alert_engine_cursor (id, last_source_finished_at)
SELECT 1, NULL
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 })
/** Прямой доступ к better-sqlite3 для сложных read-only запросов (напр. /api/alerts). */
+43
View File
@@ -300,6 +300,7 @@ export const certificateIssueJobs = sqliteTable("certificate_issue_jobs", {
id: text("id").primaryKey(),
status: text("status", { enum: ["queued", "running", "done", "failed"] }).notNull().default("queued"),
step: text("step").notNull().default("queued"),
source: text("source", { enum: ["manual", "scheduler"] }).notNull().default("manual"),
serverId: text("server_id").notNull(),
certName: text("cert_name").notNull(),
domainNames: text("domain_names").notNull(),
@@ -311,6 +312,45 @@ export const certificateIssueJobs = sqliteTable("certificate_issue_jobs", {
error: text("error"),
})
export const certificateRenewSettings = sqliteTable("certificate_renew_settings", {
id: integer("id").primaryKey(),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
intervalSec: integer("interval_sec").notNull().default(21600),
renewBeforeDays: integer("renew_before_days").notNull().default(30),
lastCollectedAt: text("last_collected_at"),
lastDurationMs: integer("last_duration_ms"),
lastError: text("last_error"),
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
})
export const backupScheduleSettings = sqliteTable("backup_schedule_settings", {
id: integer("id").primaryKey(),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
frequency: text("frequency", { enum: ["daily", "weekly", "monthly"] }).notNull().default("daily"),
hour: integer("hour").notNull().default(3),
minute: integer("minute").notNull().default(0),
weekDay: integer("week_day").notNull().default(0),
monthDay: integer("month_day").notNull().default(1),
keepCount: integer("keep_count").notNull().default(7),
format: text("format", { enum: ["rsc", "backup"] }).notNull().default("rsc"),
serverIdsJson: text("server_ids_json").notNull().default("[]"),
lastRunAt: text("last_run_at"),
lastDurationMs: integer("last_duration_ms"),
lastError: text("last_error"),
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 = все одновременно в окне тика. */
export const alertGroups = sqliteTable("alert_groups", {
id: text("id").primaryKey(),
@@ -531,6 +571,9 @@ export type EvobgpSettingsRow = typeof evobgpSettings.$inferSelect
export type AlertTelegramSettingsRow = typeof alertTelegramSettings.$inferSelect
export type AcmeSettingsRow = typeof acmeSettings.$inferSelect
export type CertificateIssueJobRow = typeof certificateIssueJobs.$inferSelect
export type CertificateRenewSettingsRow = typeof certificateRenewSettings.$inferSelect
export type BackupScheduleSettingsRow = typeof backupScheduleSettings.$inferSelect
export type BackupEntryRow = typeof backupEntries.$inferSelect
export type AlertGroupRow = typeof alertGroups.$inferSelect
export type AlertRuleRow = typeof alertRules.$inferSelect
export type AlertRuleTargetRow = typeof alertRuleTargets.$inferSelect
+33 -87
View File
@@ -1,23 +1,22 @@
import { randomUUID } from "node:crypto"
import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises"
import { readFile } from "node:fs/promises"
import path from "node:path"
import { z } from "zod"
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { putBackupScheduleSettingsSchema } from "@mmapp/contracts/backups"
import { listServersRead } from "../modules/servers/service/servers-service.js"
import { getServerRowById } from "../modules/servers/repository/servers-repository.js"
import { MikrotikClient } from "../services/mikrotik.js"
import { appendEvent } from "../modules/events/service/events-service.js"
type BackupMeta = {
id: string
serverId: string
serverName: string
filename: string
sizeBytes: number
createdAt: string
kind: "manual"
notes?: string
}
import { refreshScheduler } from "../services/scheduler.js"
import {
deleteBackupRecord,
getBackupById,
getBackupsDir,
getBackupScheduleSettings,
listBackups,
runBackupForServer,
updateBackupScheduleSettings,
type BackupMeta,
} from "../services/backup-service.js"
type BackupJobStatus = "queued" | "running" | "done" | "failed"
type BackupJob = {
@@ -32,36 +31,8 @@ type BackupJob = {
failures: Array<{ serverId: string; error: string }>
}
const BACKUPS_DIR = path.resolve(process.cwd(), "storage", "backups")
const INDEX_PATH = path.join(BACKUPS_DIR, "index.json")
const backupJobs = new Map<string, BackupJob>()
async function ensureStorage() {
await mkdir(BACKUPS_DIR, { recursive: true })
}
async function readIndex(): Promise<BackupMeta[]> {
await ensureStorage()
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 []
}
}
async function writeIndex(rows: BackupMeta[]): Promise<void> {
await ensureStorage()
await writeFile(INDEX_PATH, JSON.stringify(rows, null, 2), "utf8")
}
function fmtTs(d = new Date()): string {
const p = (n: number) => String(n).padStart(2, "0")
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}_${p(d.getHours())}-${p(d.getMinutes())}-${p(d.getSeconds())}`
}
const CreateBackupBodySchema = z.object({
serverIds: z.array(z.union([z.string(), z.number()])).min(1),
notes: z.string().max(500).optional(),
@@ -74,43 +45,12 @@ const BackupJobIdParamSchema = z.object({
jobId: z.string().min(1),
})
async function runBackupForServer(id: string, notes?: string): Promise<BackupMeta> {
const serverIdNum = Number.parseInt(id, 10)
if (!Number.isFinite(serverIdNum)) {
throw new Error("Невалидный id сервера")
}
const row = getServerRowById(serverIdNum)
if (!row) {
throw new Error("Сервер не найден")
}
const client = MikrotikClient.fromServer(row)
const script = await client.exportConfigScript()
const ts = fmtTs()
const safeServer = row.name.replace(/[^a-zA-Z0-9._-]+/g, "_")
const filename = `${safeServer}_${ts}.rsc`
const filePath = path.join(BACKUPS_DIR, filename)
await writeFile(filePath, script, "utf8")
const st = await stat(filePath)
return {
id: randomUUID(),
serverId: String(row.id),
serverName: row.name,
filename,
sizeBytes: st.size,
createdAt: new Date().toISOString(),
kind: "manual",
notes,
}
}
async function processBackupJob(job: BackupJob, ids: string[], notes?: string) {
job.status = "running"
job.startedAt = new Date().toISOString()
const indexRows = await readIndex()
for (const id of ids) {
try {
const meta = await runBackupForServer(id, notes)
indexRows.unshift(meta)
const meta = await runBackupForServer(id, "manual", notes)
job.created.push(meta)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
@@ -119,7 +59,6 @@ async function processBackupJob(job: BackupJob, ids: string[], notes?: string) {
job.completed += 1
}
}
await writeIndex(indexRows)
job.status = "done"
job.finishedAt = new Date().toISOString()
appendEvent({
@@ -141,9 +80,21 @@ async function processBackupJob(job: BackupJob, ids: string[], notes?: string) {
const backupsRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/backups", async (_req, reply) => {
const rows = await readIndex()
rows.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
return reply.send(rows)
return reply.send(listBackups())
})
app.get("/backups/schedule", async (_req, reply) => {
return reply.send(getBackupScheduleSettings())
})
app.put("/backups/schedule", async (req, reply) => {
const parsed = putBackupScheduleSettingsSchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const result = updateBackupScheduleSettings(parsed.data)
refreshScheduler()
return reply.send(result)
})
app.post("/backups/create", { schema: { body: CreateBackupBodySchema } }, async (req, reply) => {
@@ -216,10 +167,9 @@ const backupsRoutes: FastifyPluginAsyncZod = async (app) => {
})
app.get("/backups/:id/download", { schema: { params: BackupIdParamSchema } }, async (req, reply) => {
const rows = await readIndex()
const hit = rows.find((r) => r.id === req.params.id)
const hit = getBackupById(req.params.id)
if (!hit) return reply.status(404).send({ error: "Бэкап не найден" })
const filePath = path.join(BACKUPS_DIR, hit.filename)
const filePath = path.join(getBackupsDir(), hit.filename)
const content = await readFile(filePath, "utf8").catch(() => null)
if (content == null) return reply.status(404).send({ error: "Файл бэкапа не найден" })
reply.header("Content-Type", "text/plain; charset=utf-8")
@@ -228,12 +178,8 @@ const backupsRoutes: FastifyPluginAsyncZod = async (app) => {
})
app.delete("/backups/:id", { schema: { params: BackupIdParamSchema } }, async (req, reply) => {
const rows = await readIndex()
const idx = rows.findIndex((r) => r.id === req.params.id)
if (idx < 0) return reply.status(404).send({ error: "Бэкап не найден" })
const [hit] = rows.splice(idx, 1)
await writeIndex(rows)
await rm(path.join(BACKUPS_DIR, hit.filename), { force: true })
const hit = await deleteBackupRecord(req.params.id)
if (!hit) return reply.status(404).send({ error: "Бэкап не найден" })
return reply.status(204).send()
})
}
+22 -83
View File
@@ -3,101 +3,25 @@ import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import {
certificateIssueRequestSchema,
putAcmeCloudflareSettingsSchema,
putCertificateRenewSettingsSchema,
testAcmeCloudflareSettingsSchema,
} from "@mmapp/contracts/certificates"
import { appendEvent } from "../modules/events/service/events-service.js"
import { issueCertificateWithCloudflareDns, testCloudflareToken } from "../services/acme-cloudflare.js"
import { refreshScheduler } from "../services/scheduler.js"
import { testCloudflareToken } from "../services/acme-cloudflare.js"
import { queueCertificateIssueJob } from "../services/certificate-issue-runner.js"
import {
createIssueJobRecord,
getAcmeCloudflareToken,
getAcmeSettingsPublic,
getCertificateRenewSettings,
getIssueJobRecord,
getServerRowByIdString,
listCertificatesFromServers,
toIssueJobDto,
updateAcmeSettings,
updateIssueJobRecord,
updateCertificateRenewSettings,
} from "../services/certificates-service.js"
const runningJobs = new Set<string>()
async function runIssueJob(jobId: string) {
if (runningJobs.has(jobId)) return
runningJobs.add(jobId)
const row = getIssueJobRecord(jobId)
if (!row) {
runningJobs.delete(jobId)
return
}
const server = getServerRowByIdString(row.serverId)
if (!server) {
updateIssueJobRecord(jobId, {
status: "failed",
step: "failed",
finishedAt: new Date().toISOString(),
error: "Сервер не найден",
})
runningJobs.delete(jobId)
return
}
let domainNames: string[] = []
try {
domainNames = JSON.parse(row.domainNames) as string[]
} catch {
domainNames = []
}
const trustStore = row.trustStore.split(",").map((s) => s.trim()).filter(Boolean)
const startedAt = new Date().toISOString()
updateIssueJobRecord(jobId, { status: "running", step: "acme_order", startedAt, error: null })
try {
await issueCertificateWithCloudflareDns({
server,
certName: row.certName,
domainNames,
keyType: row.keyType === "ec256" ? "ec256" : "rsa2048",
trustStore: trustStore.length > 0 ? trustStore : ["www", "api"],
onStep: (step) => updateIssueJobRecord(jobId, { step }),
})
updateIssueJobRecord(jobId, {
status: "done",
step: "done",
finishedAt: new Date().toISOString(),
error: null,
})
appendEvent({
level: "info",
eventType: "certificates.issue.done",
sourceModule: "certificates",
title: "Сертификат выпущен",
message: `${row.certName} · ${domainNames.join(", ")} · ${server.name}`,
entityType: "server",
entityId: String(server.id),
})
} catch (e) {
updateIssueJobRecord(jobId, {
status: "failed",
step: "failed",
finishedAt: new Date().toISOString(),
error: e instanceof Error ? e.message : String(e),
})
appendEvent({
level: "warning",
eventType: "certificates.issue.failed",
sourceModule: "certificates",
title: "Ошибка выпуска сертификата",
message: e instanceof Error ? e.message : String(e),
entityType: "server",
entityId: String(server.id),
})
} finally {
runningJobs.delete(jobId)
}
}
const certificatesRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/certificates", async (_req, reply) => {
return reply.send(await listCertificatesFromServers())
@@ -119,6 +43,20 @@ const certificatesRoutes: FastifyPluginAsyncZod = async (app) => {
return reply.send(updateAcmeSettings(parsed.data))
})
app.get("/certificates/renew-settings", async (_req, reply) => {
return reply.send(getCertificateRenewSettings())
})
app.put("/certificates/renew-settings", async (req, reply) => {
const parsed = putCertificateRenewSettingsSchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const result = updateCertificateRenewSettings(parsed.data)
refreshScheduler()
return reply.send(result)
})
app.post("/certificates/acme-settings/test", async (req, reply) => {
const parsed = testAcmeCloudflareSettingsSchema.safeParse(req.body ?? {})
if (!parsed.success) {
@@ -157,8 +95,9 @@ const certificatesRoutes: FastifyPluginAsyncZod = async (app) => {
domainNames: parsed.data.domainNames.map((d) => d.trim()).filter(Boolean),
keyType: parsed.data.keyType ?? "rsa2048",
trustStore,
source: "manual",
})
queueMicrotask(() => { void runIssueJob(jobId) })
queueCertificateIssueJob(jobId)
return reply.send({ jobId })
})
@@ -0,0 +1,136 @@
import { appendEvent } from "../modules/events/service/events-service.js"
import type { BackupsRunSnapshot } from "../types/scheduler-run-snapshot.js"
import { SCHEDULER_RUN_SNAPSHOT_VERSION } from "../types/scheduler-run-snapshot.js"
import {
getBackupScheduleSettings,
isBackupDue,
pruneBackupsForServer,
resolveBackupServerIds,
runBackupForServer,
touchBackupScheduleRunMeta,
} from "./backup-service.js"
let collecting = false
export function getBackupSchedulerCollectorState(): { running: boolean } {
return { running: collecting }
}
export async function collectScheduledBackupsOnce(): Promise<BackupsRunSnapshot> {
const sampledAt = new Date().toISOString()
const settings = getBackupScheduleSettings()
const snapshot: BackupsRunSnapshot = {
v: SCHEDULER_RUN_SNAPSHOT_VERSION,
job: "backups",
sampledAt,
due: false,
created: 0,
failures: 0,
pruned: 0,
errors: [],
}
if (collecting) {
snapshot.skipped = true
return snapshot
}
if (!settings.enabled) {
snapshot.skipped = true
return snapshot
}
const due = isBackupDue(new Date(), settings, settings.lastRunAt)
snapshot.due = due
if (!due) {
return snapshot
}
if (settings.format !== "rsc") {
snapshot.skipped = true
snapshot.errors = ["Формат backup пока не поддерживается, используйте rsc"]
touchBackupScheduleRunMeta({
lastRunAt: sampledAt,
lastDurationMs: 0,
lastError: snapshot.errors[0],
})
return snapshot
}
collecting = true
const started = Date.now()
const serverIds = resolveBackupServerIds(settings)
appendEvent({
level: "info",
eventType: "backups.job.started",
sourceModule: "backups",
title: "Запущен плановый бэкап",
message: `Серверов в очереди: ${serverIds.length}`,
entityType: "backup_job",
entityId: "scheduler",
payload: { serverIds, scheduled: true },
})
try {
for (const id of serverIds) {
try {
await runBackupForServer(id, "auto")
snapshot.created += 1
} catch (e) {
const message = e instanceof Error ? e.message : String(e)
snapshot.failures += 1
snapshot.errors?.push(`${id}: ${message}`)
}
}
for (const id of serverIds) {
snapshot.pruned += await pruneBackupsForServer(id, settings.keepCount)
}
touchBackupScheduleRunMeta({
lastRunAt: sampledAt,
lastDurationMs: Date.now() - started,
lastError: snapshot.errors?.length ? snapshot.errors.join("; ") : null,
})
appendEvent({
level: snapshot.failures > 0 ? "warning" : "info",
eventType: "backups.job.done",
sourceModule: "backups",
title: snapshot.failures > 0 ? "Плановый бэкап завершен с ошибками" : "Плановый бэкап завершен",
message: `Создано: ${snapshot.created}, ошибок: ${snapshot.failures}`,
entityType: "backup_job",
entityId: "scheduler",
payload: {
total: serverIds.length,
completed: snapshot.created + snapshot.failures,
failures: snapshot.errors,
pruned: snapshot.pruned,
},
})
return snapshot
} catch (e) {
const message = e instanceof Error ? e.message : String(e)
snapshot.fatalError = message
snapshot.errors?.push(message)
touchBackupScheduleRunMeta({
lastRunAt: sampledAt,
lastDurationMs: Date.now() - started,
lastError: message,
})
appendEvent({
level: "critical",
eventType: "backups.job.failed",
sourceModule: "backups",
title: "Плановый бэкап прерван",
message,
entityType: "backup_job",
entityId: "scheduler",
})
return snapshot
} finally {
collecting = false
}
}
+273
View File
@@ -0,0 +1,273 @@
import { randomUUID } from "node:crypto"
import { mkdir, rm, stat, writeFile } from "node:fs/promises"
import path from "node:path"
import { desc, eq } from "drizzle-orm"
import type { BackupScheduleSettingsDto } from "@mmapp/contracts/backups"
import { db } from "../db/index.js"
import { backupEntries, backupScheduleSettings } from "../db/schema.js"
import { getServerRowById } from "../modules/servers/repository/servers-repository.js"
import { listServersRead } from "../modules/servers/service/servers-service.js"
import { MikrotikClient } from "./mikrotik.js"
const SETTINGS_ID = 1
const BACKUPS_DIR = path.resolve(process.cwd(), "storage", "backups")
export type BackupMeta = {
id: string
serverId: string
serverName: string
filename: string
sizeBytes: number
createdAt: string
kind: "manual" | "auto"
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> {
await mkdir(BACKUPS_DIR, { recursive: true })
}
export function listBackups(): BackupMeta[] {
return db.select().from(backupEntries).orderBy(desc(backupEntries.createdAt)).all().map(rowToMeta)
}
export function getBackupById(id: string): BackupMeta | null {
const row = db.select().from(backupEntries).where(eq(backupEntries.id, id)).limit(1).all()[0]
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 {
const p = (n: number) => String(n).padStart(2, "0")
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}_${p(d.getHours())}-${p(d.getMinutes())}-${p(d.getSeconds())}`
}
function parseServerIds(raw: string): string[] {
try {
const parsed = JSON.parse(raw) as unknown
if (!Array.isArray(parsed)) return []
return parsed.map(String).filter(Boolean)
} catch {
return []
}
}
function getBackupScheduleSettingsRow() {
return db.select().from(backupScheduleSettings).where(eq(backupScheduleSettings.id, SETTINGS_ID)).limit(1).all()[0]
?? {
id: SETTINGS_ID,
enabled: true,
frequency: "daily" as const,
hour: 3,
minute: 0,
weekDay: 0,
monthDay: 1,
keepCount: 7,
format: "rsc" as const,
serverIdsJson: "[]",
lastRunAt: null,
lastDurationMs: null,
lastError: null,
updatedAt: new Date().toISOString(),
}
}
export function getBackupScheduleSettings(): BackupScheduleSettingsDto {
const row = getBackupScheduleSettingsRow()
return {
enabled: row.enabled,
frequency: row.frequency,
hour: row.hour,
minute: row.minute,
weekDay: row.weekDay,
monthDay: row.monthDay,
keepCount: row.keepCount,
format: row.format,
serverIds: parseServerIds(row.serverIdsJson),
lastRunAt: row.lastRunAt ?? null,
lastDurationMs: row.lastDurationMs ?? null,
lastError: row.lastError ?? null,
updatedAt: row.updatedAt,
}
}
export function updateBackupScheduleSettings(patch: Partial<{
enabled: boolean
frequency: "daily" | "weekly" | "monthly"
hour: number
minute: number
weekDay: number
monthDay: number
keepCount: number
format: "rsc" | "backup"
serverIds: string[]
}>) {
const prev = getBackupScheduleSettingsRow()
const now = new Date().toISOString()
const next = {
enabled: patch.enabled ?? prev.enabled,
frequency: patch.frequency ?? prev.frequency,
hour: patch.hour ?? prev.hour,
minute: patch.minute ?? prev.minute,
weekDay: patch.weekDay ?? prev.weekDay,
monthDay: patch.monthDay ?? prev.monthDay,
keepCount: patch.keepCount ?? prev.keepCount,
format: patch.format ?? prev.format,
serverIdsJson: patch.serverIds ? JSON.stringify(patch.serverIds) : prev.serverIdsJson,
updatedAt: now,
}
if (db.select().from(backupScheduleSettings).where(eq(backupScheduleSettings.id, SETTINGS_ID)).limit(1).all()[0]) {
db.update(backupScheduleSettings).set(next).where(eq(backupScheduleSettings.id, SETTINGS_ID)).run()
} else {
db.insert(backupScheduleSettings).values({ id: SETTINGS_ID, ...next }).run()
}
return getBackupScheduleSettings()
}
export function touchBackupScheduleRunMeta(patch: {
lastRunAt?: string
lastDurationMs?: number
lastError?: string | null
}) {
const prev = getBackupScheduleSettingsRow()
db.update(backupScheduleSettings).set({
lastRunAt: patch.lastRunAt ?? prev.lastRunAt,
lastDurationMs: patch.lastDurationMs ?? prev.lastDurationMs,
lastError: patch.lastError === undefined ? prev.lastError : patch.lastError,
updatedAt: new Date().toISOString(),
}).where(eq(backupScheduleSettings.id, SETTINGS_ID)).run()
}
export function resolveBackupServerIds(settings: BackupScheduleSettingsDto): string[] {
const enabled = new Set(listServersRead().map((s) => String(s.id)))
const requested = settings.serverIds.length > 0 ? settings.serverIds : [...enabled]
return [...new Set(requested)].filter((id) => enabled.has(id))
}
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()
&& a.getMonth() === b.getMonth()
&& a.getDate() === b.getDate()
&& a.getHours() === b.getHours()
&& a.getMinutes() === b.getMinutes()
}
/** Срабатывает только в минуту расписания; 60 с в UI — интервал проверки, не частота бэкапа. */
export function isBackupDue(now: Date, settings: BackupScheduleSettingsDto, lastRunAt: string | null | undefined): boolean {
if (!settings.enabled) return false
const slot = scheduledSlotForDate(now, settings)
if (!slot) return false
if (now < slot) return false
if (!sameLocalMinute(now, slot)) return false
if (lastRunAt) {
const prev = new Date(lastRunAt)
if (Number.isNaN(prev.getTime())) return true
if (sameLocalMinute(prev, slot)) return false
}
return true
}
export async function runBackupForServer(
id: string,
kind: BackupMeta["kind"],
notes?: string,
): Promise<BackupMeta> {
const serverIdNum = Number.parseInt(id, 10)
if (!Number.isFinite(serverIdNum)) {
throw new Error("Невалидный id сервера")
}
const row = getServerRowById(serverIdNum)
if (!row) {
throw new Error("Сервер не найден")
}
const client = MikrotikClient.fromServer(row)
const script = await client.exportConfigScript()
const ts = fmtTs()
const safeServer = row.name.replace(/[^a-zA-Z0-9._-]+/g, "_")
const filename = `${safeServer}_${ts}.rsc`
const filePath = path.join(BACKUPS_DIR, filename)
await ensureBackupStorage()
await writeFile(filePath, script, "utf8")
const st = await stat(filePath)
const meta: BackupMeta = {
id: randomUUID(),
serverId: String(row.id),
serverName: row.name,
filename,
sizeBytes: st.size,
createdAt: new Date().toISOString(),
kind,
notes,
}
insertBackup(meta)
return meta
}
export async function pruneBackupsForServer(serverId: string, keepCount: number): Promise<number> {
const rows = db.select().from(backupEntries)
.where(eq(backupEntries.serverId, serverId))
.orderBy(desc(backupEntries.createdAt))
.all()
if (rows.length <= keepCount) return 0
const toDelete = rows.slice(keepCount)
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 })
}
return toDelete.length
}
export function getBackupsDir(): string {
return BACKUPS_DIR
}
@@ -0,0 +1,104 @@
import { appendEvent } from "../modules/events/service/events-service.js"
import { issueCertificateWithCloudflareDns } from "./acme-cloudflare.js"
import {
getIssueJobRecord,
getServerRowByIdString,
updateIssueJobRecord,
} from "./certificates-service.js"
const runningJobs = new Set<string>()
export async function runCertificateIssueJob(jobId: string): Promise<void> {
if (runningJobs.has(jobId)) return
runningJobs.add(jobId)
const row = getIssueJobRecord(jobId)
if (!row) {
runningJobs.delete(jobId)
return
}
const server = getServerRowByIdString(row.serverId)
if (!server) {
updateIssueJobRecord(jobId, {
status: "failed",
step: "failed",
finishedAt: new Date().toISOString(),
error: "Сервер не найден",
})
runningJobs.delete(jobId)
return
}
let domainNames: string[] = []
try {
domainNames = JSON.parse(row.domainNames) as string[]
} catch {
domainNames = []
}
const trustStore = row.trustStore.split(",").map((s) => s.trim()).filter(Boolean)
const startedAt = new Date().toISOString()
const isScheduler = row.source === "scheduler"
updateIssueJobRecord(jobId, { status: "running", step: "acme_order", startedAt, error: null })
if (isScheduler) {
appendEvent({
level: "info",
eventType: "certificates.renew.started",
sourceModule: "certificates",
title: "Запущено автообновление сертификата",
message: `${row.certName} · ${domainNames.join(", ")} · ${server.name}`,
entityType: "server",
entityId: String(server.id),
})
}
try {
await issueCertificateWithCloudflareDns({
server,
certName: row.certName,
domainNames,
keyType: row.keyType === "ec256" ? "ec256" : "rsa2048",
trustStore: trustStore.length > 0 ? trustStore : ["www", "api"],
onStep: (step) => updateIssueJobRecord(jobId, { step }),
})
updateIssueJobRecord(jobId, {
status: "done",
step: "done",
finishedAt: new Date().toISOString(),
error: null,
})
appendEvent({
level: "info",
eventType: isScheduler ? "certificates.renew.done" : "certificates.issue.done",
sourceModule: "certificates",
title: isScheduler ? "Сертификат обновлён" : "Сертификат выпущен",
message: `${row.certName} · ${domainNames.join(", ")} · ${server.name}`,
entityType: "server",
entityId: String(server.id),
})
} catch (e) {
const message = e instanceof Error ? e.message : String(e)
updateIssueJobRecord(jobId, {
status: "failed",
step: "failed",
finishedAt: new Date().toISOString(),
error: message,
})
appendEvent({
level: "warning",
eventType: isScheduler ? "certificates.renew.failed" : "certificates.issue.failed",
sourceModule: "certificates",
title: isScheduler ? "Ошибка автообновления сертификата" : "Ошибка выпуска сертификата",
message,
entityType: "server",
entityId: String(server.id),
})
} finally {
runningJobs.delete(jobId)
}
}
export function queueCertificateIssueJob(jobId: string): void {
queueMicrotask(() => { void runCertificateIssueJob(jobId) })
}
@@ -0,0 +1,158 @@
import { randomUUID } from "node:crypto"
import { appendEvent } from "../modules/events/service/events-service.js"
import type { CertificatesRenewRunSnapshot } from "../types/scheduler-run-snapshot.js"
import { SCHEDULER_RUN_SNAPSHOT_VERSION } from "../types/scheduler-run-snapshot.js"
import { mapRosCertificates } from "./certificate-parse.js"
import { runCertificateIssueJob } from "./certificate-issue-runner.js"
import {
createIssueJobRecord,
getCertificateRenewSettings,
getServerRowByIdString,
hasActiveIssueJob,
listManagedCertificateTargets,
touchCertificateRenewRunMeta,
} from "./certificates-service.js"
import { MikrotikClient } from "./mikrotik.js"
let collecting = false
export function getCertificateRenewCollectorState(): { running: boolean } {
return { running: collecting }
}
export async function collectCertificatesRenewOnce(): Promise<CertificatesRenewRunSnapshot> {
const sampledAt = new Date().toISOString()
const settings = getCertificateRenewSettings()
if (collecting) {
return {
v: SCHEDULER_RUN_SNAPSHOT_VERSION,
job: "certificates_renew",
sampledAt,
skipped: true,
checked: 0,
renewed: 0,
skippedTargets: 0,
errors: [],
}
}
collecting = true
const started = Date.now()
const snapshot: CertificatesRenewRunSnapshot = {
v: SCHEDULER_RUN_SNAPSHOT_VERSION,
job: "certificates_renew",
sampledAt,
checked: 0,
renewed: 0,
skippedTargets: 0,
errors: [],
targets: [],
}
try {
if (!settings.enabled) {
snapshot.skipped = true
return snapshot
}
const targets = listManagedCertificateTargets()
for (const target of targets) {
snapshot.checked += 1
const item = {
serverId: target.serverId,
certName: target.certName,
action: "ok" as "ok" | "renewed" | "skipped" | "error",
daysLeft: null as number | null,
message: "",
}
if (hasActiveIssueJob(target.serverId, target.certName)) {
item.action = "skipped"
item.message = "Уже выполняется выпуск"
snapshot.skippedTargets += 1
snapshot.targets?.push(item)
appendEvent({
level: "info",
eventType: "certificates.renew.skipped",
sourceModule: "certificates",
title: "Автообновление пропущено",
message: `${target.certName}: уже выполняется выпуск`,
entityType: "server",
entityId: target.serverId,
})
continue
}
const server = getServerRowByIdString(target.serverId)
if (!server) {
item.action = "error"
item.message = "Сервер не найден"
snapshot.errors.push(`${target.certName}: сервер не найден`)
snapshot.targets?.push(item)
continue
}
try {
const client = MikrotikClient.fromServer(server)
const rows = await client.getCertificates()
const mapped = mapRosCertificates(server.id, server.name, rows)
const hit = mapped.find((c) => c.name === target.certName)
if (!hit) {
item.action = "error"
item.message = "Сертификат не найден на устройстве"
snapshot.errors.push(`${target.certName}: не найден на ${server.name}`)
snapshot.targets?.push(item)
continue
}
item.daysLeft = hit.daysLeft
if (hit.daysLeft > settings.renewBeforeDays) {
item.action = "ok"
item.message = `До истечения ${hit.daysLeft} дн.`
snapshot.targets?.push(item)
continue
}
const jobId = randomUUID()
createIssueJobRecord({
id: jobId,
serverId: target.serverId,
certName: target.certName,
domainNames: target.domainNames,
keyType: target.keyType,
trustStore: target.trustStore,
source: "scheduler",
})
await runCertificateIssueJob(jobId)
snapshot.renewed += 1
item.action = "renewed"
item.message = "Запущено обновление"
snapshot.targets?.push(item)
} catch (e) {
const message = e instanceof Error ? e.message : String(e)
item.action = "error"
item.message = message
snapshot.errors.push(`${target.certName}: ${message}`)
snapshot.targets?.push(item)
}
}
touchCertificateRenewRunMeta({
lastCollectedAt: sampledAt,
lastDurationMs: Date.now() - started,
lastError: snapshot.errors.length > 0 ? snapshot.errors.join("; ") : null,
})
return snapshot
} catch (e) {
const message = e instanceof Error ? e.message : String(e)
snapshot.fatalError = message
touchCertificateRenewRunMeta({
lastCollectedAt: sampledAt,
lastDurationMs: Date.now() - started,
lastError: message,
})
return snapshot
} finally {
collecting = false
}
}
+119 -3
View File
@@ -1,12 +1,26 @@
import { eq } from "drizzle-orm"
import type { CertificateDto } from "@mmapp/contracts/certificates"
import { desc, eq, inArray } from "drizzle-orm"
import type { CertificateDto, CertificateRenewSettingsDto } from "@mmapp/contracts/certificates"
import { db } from "../db/index.js"
import { acmeSettings, certificateIssueJobs, servers } from "../db/schema.js"
import {
acmeSettings,
certificateIssueJobs,
certificateRenewSettings,
servers,
} from "../db/schema.js"
import { mapRosCertificates } from "./certificate-parse.js"
import { MikrotikClient } from "./mikrotik.js"
const SETTINGS_ID = 1
export type ManagedCertificateTarget = {
serverId: string
certName: string
domainNames: string[]
keyType: string
trustStore: string
finishedAt: string
}
export async function listCertificatesFromServers(): Promise<{
certificates: CertificateDto[]
failures: Array<{ serverId: string; serverName?: string; error: string }>
@@ -109,12 +123,14 @@ export function createIssueJobRecord(input: {
domainNames: string[]
keyType: string
trustStore: string
source?: "manual" | "scheduler"
}) {
const now = new Date().toISOString()
db.insert(certificateIssueJobs).values({
id: input.id,
status: "queued",
step: "queued",
source: input.source ?? "manual",
serverId: input.serverId,
certName: input.certName,
domainNames: JSON.stringify(input.domainNames),
@@ -168,3 +184,103 @@ export function getServerRowByIdString(serverId: string) {
if (!Number.isFinite(id)) return null
return db.select().from(servers).where(eq(servers.id, id)).limit(1).all()[0] ?? null
}
function getCertificateRenewSettingsRow() {
return db.select().from(certificateRenewSettings).where(eq(certificateRenewSettings.id, SETTINGS_ID)).limit(1).all()[0]
?? {
id: SETTINGS_ID,
enabled: true,
intervalSec: 21600,
renewBeforeDays: 30,
lastCollectedAt: null,
lastDurationMs: null,
lastError: null,
updatedAt: new Date().toISOString(),
}
}
export function getCertificateRenewSettings(): CertificateRenewSettingsDto {
const row = getCertificateRenewSettingsRow()
return {
enabled: row.enabled,
intervalSec: row.intervalSec,
renewBeforeDays: row.renewBeforeDays,
lastCollectedAt: row.lastCollectedAt ?? null,
lastDurationMs: row.lastDurationMs ?? null,
lastError: row.lastError ?? null,
updatedAt: row.updatedAt,
}
}
export function updateCertificateRenewSettings(patch: {
enabled?: boolean
intervalSec?: number
renewBeforeDays?: number
}) {
const prev = getCertificateRenewSettingsRow()
const now = new Date().toISOString()
const next = {
enabled: patch.enabled ?? prev.enabled,
intervalSec: patch.intervalSec ?? prev.intervalSec,
renewBeforeDays: patch.renewBeforeDays ?? prev.renewBeforeDays,
updatedAt: now,
}
if (db.select().from(certificateRenewSettings).where(eq(certificateRenewSettings.id, SETTINGS_ID)).limit(1).all()[0]) {
db.update(certificateRenewSettings).set(next).where(eq(certificateRenewSettings.id, SETTINGS_ID)).run()
} else {
db.insert(certificateRenewSettings).values({ id: SETTINGS_ID, ...next }).run()
}
return getCertificateRenewSettings()
}
export function touchCertificateRenewRunMeta(patch: {
lastCollectedAt?: string
lastDurationMs?: number
lastError?: string | null
}) {
const prev = getCertificateRenewSettingsRow()
db.update(certificateRenewSettings).set({
lastCollectedAt: patch.lastCollectedAt ?? prev.lastCollectedAt,
lastDurationMs: patch.lastDurationMs ?? prev.lastDurationMs,
lastError: patch.lastError === undefined ? prev.lastError : patch.lastError,
updatedAt: new Date().toISOString(),
}).where(eq(certificateRenewSettings.id, SETTINGS_ID)).run()
}
export function listManagedCertificateTargets(): ManagedCertificateTarget[] {
const rows = db.select().from(certificateIssueJobs)
.where(eq(certificateIssueJobs.status, "done"))
.orderBy(desc(certificateIssueJobs.finishedAt), desc(certificateIssueJobs.requestedAt))
.all()
const byKey = new Map<string, ManagedCertificateTarget>()
for (const row of rows) {
const key = `${row.serverId}::${row.certName}`
if (byKey.has(key)) continue
let domainNames: string[] = []
try {
const parsed = JSON.parse(row.domainNames) as unknown
if (Array.isArray(parsed)) domainNames = parsed.map(String)
} catch {
domainNames = []
}
byKey.set(key, {
serverId: row.serverId,
certName: row.certName,
domainNames,
keyType: row.keyType,
trustStore: row.trustStore,
finishedAt: row.finishedAt ?? row.requestedAt,
})
}
return [...byKey.values()]
}
export function hasActiveIssueJob(serverId: string, certName: string): boolean {
const row = db.select().from(certificateIssueJobs)
.where(inArray(certificateIssueJobs.status, ["queued", "running"]))
.orderBy(desc(certificateIssueJobs.requestedAt))
.all()
.find((r) => r.serverId === serverId && r.certName === certName)
return Boolean(row)
}
+40
View File
@@ -42,6 +42,10 @@ import {
collectInternetPathSnapshotOnce,
getInternetPathSettings,
} from "./internet-path-collector.js"
import { collectCertificatesRenewOnce } from "./certificate-renew-collector.js"
import { getCertificateRenewSettings } from "./certificates-service.js"
import { collectScheduledBackupsOnce } from "./backup-scheduler-collector.js"
import { getBackupScheduleSettings } from "./backup-service.js"
import {
endSchedulerJob,
isSchedulerJobRunning,
@@ -57,6 +61,8 @@ export const JOB_KEYS = [
"uptime_speed",
"internet_path",
"gre_bgp",
"certificates_renew",
"backups",
"alert_engine",
] as const
export type SchedulerJobKey = (typeof JOB_KEYS)[number]
@@ -119,6 +125,12 @@ async function runSchedulerJobBody(jobKey: SchedulerJobKey): Promise<void> {
case "internet_path":
snapshot = await collectInternetPathSnapshotOnce()
break
case "certificates_renew":
snapshot = await collectCertificatesRenewOnce()
break
case "backups":
snapshot = await collectScheduledBackupsOnce()
break
case "alert_engine": {
const r = await runAlertEngineOnce()
snapshot = {
@@ -322,6 +334,30 @@ export function refreshScheduler(): void {
}, greBgpMs),
)
const certRenew = getCertificateRenewSettings()
if (certRenew.enabled) {
const certRenewMs = Math.max(300_000, certRenew.intervalSec * 1000)
void executeSchedulerJob("certificates_renew").catch(() => {})
timers.set(
"certificates_renew",
setInterval(() => {
void executeSchedulerJob("certificates_renew").catch(() => {})
}, certRenewMs),
)
}
const backupSchedule = getBackupScheduleSettings()
if (backupSchedule.enabled) {
const backupMs = 60_000
void executeSchedulerJob("backups").catch(() => {})
timers.set(
"backups",
setInterval(() => {
void executeSchedulerJob("backups").catch(() => {})
}, backupMs),
)
}
const alertMs = 20_000
void executeSchedulerJob("alert_engine").catch(() => {})
timers.set(
@@ -353,6 +389,8 @@ export function getSchedulerStatus() {
const uptime = getUptimeSettings()
const apiPing = getServersApiPingSettings()
const internetPath = getInternetPathSettings()
const certRenew = getCertificateRenewSettings()
const backupSchedule = getBackupScheduleSettings()
const resOn = uptime.resourcesEnabled ?? uptime.enabled
const pingOn = uptime.pingEnabled ?? uptime.enabled
@@ -366,6 +404,8 @@ export function getSchedulerStatus() {
uptime_speed: { enabled: spdOn, intervalSec: uptime.speedIntervalSec },
internet_path: { enabled: internetPath.enabled, intervalSec: internetPath.intervalSec },
gre_bgp: { enabled: true, intervalSec: 30 },
certificates_renew: { enabled: certRenew.enabled, intervalSec: certRenew.intervalSec },
backups: { enabled: backupSchedule.enabled, intervalSec: 60 },
alert_engine: { enabled: true, intervalSec: 20 },
}
@@ -192,6 +192,40 @@ export interface InternetPathRunSnapshot {
fatalError?: string
}
export interface CertificatesRenewTargetSnapshot {
serverId: string
certName: string
action: "ok" | "renewed" | "skipped" | "error"
daysLeft: number | null
message: string
}
export interface CertificatesRenewRunSnapshot {
v: typeof SCHEDULER_RUN_SNAPSHOT_VERSION
job: "certificates_renew"
sampledAt: string
skipped?: boolean
fatalError?: string
checked: number
renewed: number
skippedTargets: number
errors: string[]
targets?: CertificatesRenewTargetSnapshot[]
}
export interface BackupsRunSnapshot {
v: typeof SCHEDULER_RUN_SNAPSHOT_VERSION
job: "backups"
sampledAt: string
skipped?: boolean
due: boolean
created: number
failures: number
pruned: number
errors?: string[]
fatalError?: string
}
export type SchedulerRunSnapshot =
| TrafficRunSnapshot
| ResourcesRunSnapshot
@@ -200,4 +234,6 @@ export type SchedulerRunSnapshot =
| ServersRestPingRunSnapshot
| GreBgpSnapshotRunSnapshot
| InternetPathRunSnapshot
| CertificatesRenewRunSnapshot
| BackupsRunSnapshot
| AlertEngineRunSnapshot
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
-111
View File
@@ -1,111 +0,0 @@
[
{
"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
@@ -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
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
+4
View File
@@ -18,8 +18,12 @@ services:
image: git.shts.su/denozord/mikrotikmanager-frontend:latest
container_name: mmapp-frontend
restart: unless-stopped
depends_on:
- backend
ports:
- "3000:3000"
environment:
BACKEND_INTERNAL_URL: http://backend:8000
labels:
mmapp.updater.managed: "true"
mmapp.updater.target: frontend
+12 -5
View File
@@ -59,9 +59,13 @@ function normalizeBackendUrl(url: string): string {
}
export function DataSourceProvider({ children }: { children: React.ReactNode }) {
const [mode, setModeState] = useState<DataSourceMode>(defaultDataSourceMode)
const [backendUrl, setBackendUrlState] = useState(LOCAL_DEFAULT_BACKEND_URL)
const [prefsHydrated, setPrefsHydrated] = useState(false)
const [mode, setModeState] = useState<DataSourceMode>(() =>
typeof window === "undefined" ? defaultDataSourceMode() : readStoredMode(),
)
const [backendUrl, setBackendUrlState] = useState(() =>
typeof window === "undefined" ? LOCAL_DEFAULT_BACKEND_URL : readStoredBackendUrl(),
)
const [prefsHydrated, setPrefsHydrated] = useState(() => typeof window !== "undefined")
const [backendStatus, setBackendStatus] = useState<boolean | undefined>(undefined)
const backendUrlLocked = isBackendUrlLocked()
const mockModeAvailable = isMockDataSourceAvailable()
@@ -91,9 +95,12 @@ export function DataSourceProvider({ children }: { children: React.ReactNode })
}, [backendUrlLocked])
const checkBackend = useCallback(async () => {
const url = normalizeBackendUrl(backendUrl)
const healthUrl =
configuredBackendUrl().kind === "same-origin"
? "/health"
: `${normalizeBackendUrl(backendUrl)}/health`
try {
const res = await fetch(`${url}/health`, { signal: AbortSignal.timeout(3000) })
const res = await fetch(healthUrl, { signal: AbortSignal.timeout(3000) })
setBackendStatus(res.ok)
} catch {
setBackendStatus(false)
+27
View File
@@ -72,6 +72,31 @@ export interface InternetPathRunSnapshot {
fatalError?: string
}
export interface CertificatesRenewRunSnapshot {
v: number
job: "certificates_renew"
sampledAt: string
skipped?: boolean
fatalError?: string
checked: number
renewed: number
skippedTargets: number
errors: string[]
}
export interface BackupsRunSnapshot {
v: number
job: "backups"
sampledAt: string
skipped?: boolean
due: boolean
created: number
failures: number
pruned: number
errors?: string[]
fatalError?: string
}
export type SchedulerRunSnapshot =
| TrafficRunSnapshot
| ResourcesRunSnapshot
@@ -80,6 +105,8 @@ export type SchedulerRunSnapshot =
| ServersRestPingRunSnapshot
| GreBgpSnapshotRunSnapshot
| InternetPathRunSnapshot
| CertificatesRenewRunSnapshot
| BackupsRunSnapshot
| AlertEngineRunSnapshot
export interface TrafficServerSnapshot {
+8
View File
@@ -9,6 +9,8 @@ export const SCHEDULER_JOB_KEYS = [
"uptime_speed",
"internet_path",
"gre_bgp",
"certificates_renew",
"backups",
"alert_engine",
] as const
export type SchedulerJobKey = (typeof SCHEDULER_JOB_KEYS)[number]
@@ -21,6 +23,8 @@ export const SCHEDULER_JOB_LABELS: Record<string, string> = {
uptime_speed: "Uptime: speed",
internet_path: "Internet Path",
gre_bgp: "GRE + BGP",
certificates_renew: "Сертификаты: автообновление",
backups: "Бэкапы",
alert_engine: "Оповещения",
}
@@ -35,6 +39,10 @@ export const SCHEDULER_JOB_DESCRIPTIONS: Record<string, string> = {
internet_path: "Снимок данных для карты интернет-маршрута на dashboard (WAN/JH/EN, route+runtime, speed-пробы).",
gre_bgp:
"Опрос GRE-туннелей и BGP-сессий на включённых серверах, запись сэмплов в SQLite для движка оповещений.",
certificates_renew:
"Проверка сертификатов, выпущенных через UI, и автообновление через ACME DNS-01 (Cloudflare) до истечения срока.",
backups:
"Плановые бэкапы RouterOS по расписанию со страницы «Бэкапы»; тик планировщика раз в минуту.",
alert_engine:
"Оценка правил по данным из SQLite (сэмплы пишут джобы сбора, в т.ч. «GRE + BGP» и «Серверы: REST API»).",
}
+4
View File
@@ -29,6 +29,10 @@
"./certificates": {
"types": "./dist/certificates.d.ts",
"default": "./dist/certificates.js"
},
"./backups": {
"types": "./dist/backups.d.ts",
"default": "./dist/backups.js"
}
},
"dependencies": {
+34
View File
@@ -0,0 +1,34 @@
import { z } from "zod"
export const backupFrequencySchema = z.enum(["daily", "weekly", "monthly"])
export const backupFormatSchema = z.enum(["rsc", "backup"])
export const backupScheduleSettingsDtoSchema = z.object({
enabled: z.boolean(),
frequency: backupFrequencySchema,
hour: z.number().int().min(0).max(23),
minute: z.number().int().min(0).max(59),
weekDay: z.number().int().min(0).max(6),
monthDay: z.number().int().min(1).max(28),
keepCount: z.number().int().min(1).max(365),
format: backupFormatSchema,
serverIds: z.array(z.string()),
lastRunAt: z.string().nullable().optional(),
lastDurationMs: z.number().int().nullable().optional(),
lastError: z.string().nullable().optional(),
updatedAt: z.string().optional(),
})
export const putBackupScheduleSettingsSchema = z.object({
enabled: z.boolean().optional(),
frequency: backupFrequencySchema.optional(),
hour: z.number().int().min(0).max(23).optional(),
minute: z.number().int().min(0).max(59).optional(),
weekDay: z.number().int().min(0).max(6).optional(),
monthDay: z.number().int().min(1).max(28).optional(),
keepCount: z.number().int().min(1).max(365).optional(),
format: backupFormatSchema.optional(),
serverIds: z.array(z.string()).optional(),
})
export type BackupScheduleSettingsDto = z.infer<typeof backupScheduleSettingsDtoSchema>
+17
View File
@@ -103,8 +103,25 @@ export const testAcmeCloudflareSettingsSchema = z.object({
cloudflareApiToken: z.string().optional(),
})
export const certificateRenewSettingsDtoSchema = z.object({
enabled: z.boolean(),
intervalSec: z.number().int().min(300),
renewBeforeDays: z.number().int().min(1).max(90),
lastCollectedAt: z.string().nullable().optional(),
lastDurationMs: z.number().int().nullable().optional(),
lastError: z.string().nullable().optional(),
updatedAt: z.string().optional(),
})
export const putCertificateRenewSettingsSchema = z.object({
enabled: z.boolean().optional(),
intervalSec: z.number().int().min(300).optional(),
renewBeforeDays: z.number().int().min(1).max(90).optional(),
})
export type CertificateDto = z.infer<typeof certificateDtoSchema>
export type CertificatesListResponse = z.infer<typeof certificatesListResponseSchema>
export type CertificateIssueRequest = z.infer<typeof certificateIssueRequestSchema>
export type CertificateIssueJob = z.infer<typeof certificateIssueJobSchema>
export type AcmeCloudflareSettingsDto = z.infer<typeof acmeCloudflareSettingsDtoSchema>
export type CertificateRenewSettingsDto = z.infer<typeof certificateRenewSettingsDtoSchema>
+1
View File
@@ -2,3 +2,4 @@ export * from "./servers.js"
export * from "./alerts.js"
export * from "./events.js"
export * from "./certificates.js"
export * from "./backups.js"
+16 -1
View File
@@ -1,4 +1,5 @@
import { requestJson } from "@/shared/api/http-client"
import type { BackupScheduleSettingsDto } from "@mmapp/contracts/backups"
export type BackupItem = {
id: string
@@ -7,7 +8,7 @@ export type BackupItem = {
filename: string
sizeBytes: number
createdAt: string
kind: "manual"
kind: "manual" | "auto"
notes?: string
}
@@ -66,3 +67,17 @@ export async function getBackupJob(baseUrl: string, jobId: string): Promise<Back
export async function deleteBackup(baseUrl: string, id: string): Promise<void> {
await requestJson<void>(baseUrl, `/api/backups/${id}`, { method: "DELETE" })
}
export async function getBackupScheduleSettings(baseUrl: string): Promise<BackupScheduleSettingsDto> {
return requestJson<BackupScheduleSettingsDto>(baseUrl, "/api/backups/schedule")
}
export async function putBackupScheduleSettings(
baseUrl: string,
payload: Partial<BackupScheduleSettingsDto>,
): Promise<BackupScheduleSettingsDto> {
return requestJson<BackupScheduleSettingsDto>(baseUrl, "/api/backups/schedule", {
method: "PUT",
body: JSON.stringify(payload),
})
}
+19
View File
@@ -2,6 +2,7 @@ import type {
AcmeCloudflareSettingsDto,
CertificateIssueJob,
CertificateIssueRequest,
CertificateRenewSettingsDto,
CertificatesListResponse,
} from "@mmapp/contracts/certificates"
import { requestJson } from "@/shared/api/http-client"
@@ -58,3 +59,21 @@ export async function testAcmeSettings(
body: JSON.stringify(payload ?? {}),
})
}
export async function getCertificateRenewSettings(baseUrl: string): Promise<CertificateRenewSettingsDto> {
return requestJson<CertificateRenewSettingsDto>(baseUrl, "/api/certificates/renew-settings")
}
export async function putCertificateRenewSettings(
baseUrl: string,
payload: {
enabled?: boolean
intervalSec?: number
renewBeforeDays?: number
},
): Promise<CertificateRenewSettingsDto> {
return requestJson<CertificateRenewSettingsDto>(baseUrl, "/api/certificates/renew-settings", {
method: "PUT",
body: JSON.stringify(payload),
})
}
+10 -1
View File
@@ -1,3 +1,5 @@
import { configuredBackendUrl } from "@/lib/backend-url"
export class ApiClientError extends Error {
constructor(
message: string,
@@ -13,13 +15,20 @@ function trimBaseUrl(baseUrl: string): string {
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>(
baseUrl: string,
path: string,
init?: RequestInit,
): Promise<T> {
const hasBody = init?.body != null
const res = await fetch(trimBaseUrl(baseUrl) + path, {
const res = await fetch(resolveRequestUrl(baseUrl, path), {
...init,
headers: {
...(hasBody ? { "Content-Type": "application/json" } : {}),
+1 -1
View File
File diff suppressed because one or more lines are too long