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

This commit is contained in:
Denozordec
2026-05-12 21:46:11 +07:00
parent 4b8cf83ee9
commit 43ab17b253
33 changed files with 118807 additions and 179 deletions
+42 -5
View File
@@ -20,7 +20,7 @@ import { cn } from "@/lib/utils"
import { useDataSource } from "@/lib/data-source" import { useDataSource } from "@/lib/data-source"
import { listServers } from "@/shared/api/servers" import { listServers } from "@/shared/api/servers"
import { toFrontendServer } from "@/entities/server/model/mappers" 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" import { toast } from "sonner"
// ─── small UI helpers ───────────────────────────────────────────────────────── // ─── small UI helpers ─────────────────────────────────────────────────────────
@@ -116,6 +116,7 @@ export default function BackupsPage() {
const [opBusy, setOpBusy] = useState(false) const [opBusy, setOpBusy] = useState(false)
const [opError, setOpError] = useState<string | null>(null) const [opError, setOpError] = useState<string | null>(null)
const [backupJobId, setBackupJobId] = useState<string | null>(null) const [backupJobId, setBackupJobId] = useState<string | null>(null)
const [scheduleSaveBusy, setScheduleSaveBusy] = useState(false)
// Schedule // Schedule
const [schedule, setSchedule] = useState(defaultSchedule) const [schedule, setSchedule] = useState(defaultSchedule)
@@ -140,7 +141,29 @@ export default function BackupsPage() {
} }
function handleSave() { 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 // Manual backup sheet
@@ -171,14 +194,28 @@ export default function BackupsPage() {
setLoading(true) setLoading(true)
setOpError(null) setOpError(null)
try { try {
const [serversRows, backupsRows] = await Promise.all([ const [serversRows, backupsRows, scheduleRow] = await Promise.all([
listServers(backendUrl), listServers(backendUrl),
listBackups(backendUrl), listBackups(backendUrl),
getBackupScheduleSettings(backendUrl),
]) ])
const mappedServers = serversRows.map((s) => toFrontendServer(s)) const mappedServers = serversRows.map((s) => toFrontendServer(s))
setLiveServers(mappedServers) setLiveServers(mappedServers)
setBackupList(backupsRows.map(mapApiBackupToUi)) 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) { } catch (e) {
setOpError(e instanceof Error ? e.message : "Ошибка загрузки данных") setOpError(e instanceof Error ? e.message : "Ошибка загрузки данных")
} finally { } finally {
@@ -711,7 +748,7 @@ export default function BackupsPage() {
{/* Save button */} {/* Save button */}
<div className="lg:col-span-2 flex items-center gap-3"> <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> </Button>
</div> </div>
+112 -6
View File
@@ -31,6 +31,8 @@ import {
type AlertEngineRunSnapshot, type AlertEngineRunSnapshot,
type GreBgpSnapshotRunSnapshot, type GreBgpSnapshotRunSnapshot,
type InternetPathRunSnapshot, type InternetPathRunSnapshot,
type CertificatesRenewRunSnapshot,
type BackupsRunSnapshot,
type PingRunSnapshot, type PingRunSnapshot,
type ResourcesRunSnapshot, type ResourcesRunSnapshot,
type SchedulerRunSnapshot, type SchedulerRunSnapshot,
@@ -464,6 +466,73 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
</div> </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") { if (snap.job === "alert_engine") {
const a = snap as AlertEngineRunSnapshot const a = snap as AlertEngineRunSnapshot
const transitionRu = (t: AlertEngineRuleDiagSnapshot["hitTransition"]) => { const transitionRu = (t: AlertEngineRuleDiagSnapshot["hitTransition"]) => {
@@ -683,6 +752,9 @@ export default function DataCollectionPage() {
const [draftSpeedEnabled, setDraftSpeedEnabled] = useState(true) const [draftSpeedEnabled, setDraftSpeedEnabled] = useState(true)
const [draftInternetPathEnabled, setDraftInternetPathEnabled] = useState(true) const [draftInternetPathEnabled, setDraftInternetPathEnabled] = useState(true)
const [internetPathIntervalDraft, setInternetPathIntervalDraft] = useState("300") 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 [schedulerRuns, setSchedulerRuns] = useState<SchedulerRunRowDto[]>([])
const [runFilterJobKey, setRunFilterJobKey] = useState<string>("") const [runFilterJobKey, setRunFilterJobKey] = useState<string>("")
const [runNowJobKey, setRunNowJobKey] = useState<string | null>(null) const [runNowJobKey, setRunNowJobKey] = useState<string | null>(null)
@@ -714,11 +786,12 @@ 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, runsRes] = await Promise.all([ const [traffic, serversApi, uptime, internetPath, certRenew, runsRes] = await Promise.all([
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"),
apiFetch<CollectorSettingsDto>("/api/internet-path/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}`), apiFetch<{ runs: SchedulerRunRowDto[] }>(`/api/scheduler/runs${runsQuery}`),
]) ])
setTrafficCollector(traffic) setTrafficCollector(traffic)
@@ -740,6 +813,9 @@ export default function DataCollectionPage() {
setDraftSpeedEnabled(!!(uptime.speedEnabled ?? uptime.enabled)) setDraftSpeedEnabled(!!(uptime.speedEnabled ?? uptime.enabled))
setDraftInternetPathEnabled(!!internetPath.enabled) setDraftInternetPathEnabled(!!internetPath.enabled)
setInternetPathIntervalDraft(String(internetPath.intervalSec ?? 300)) setInternetPathIntervalDraft(String(internetPath.intervalSec ?? 300))
setDraftCertRenewEnabled(!!certRenew.enabled)
setCertRenewIntervalDraft(String(certRenew.intervalSec ?? 21600))
setRenewBeforeDaysDraft(String(certRenew.renewBeforeDays ?? 30))
} catch (e) { } catch (e) {
setCollectorError(e instanceof Error ? e.message : "Не удалось загрузить данные") setCollectorError(e instanceof Error ? e.message : "Не удалось загрузить данные")
} finally { } finally {
@@ -781,8 +857,10 @@ export default function DataCollectionPage() {
if (draftPingEnabled) n += 1 if (draftPingEnabled) n += 1
if (draftSpeedEnabled) n += 1 if (draftSpeedEnabled) n += 1
if (draftInternetPathEnabled) n += 1 if (draftInternetPathEnabled) n += 1
if (draftCertRenewEnabled) n += 1
return n return n
}, [ }, [
draftCertRenewEnabled,
draftInternetPathEnabled, draftInternetPathEnabled,
draftPingEnabled, draftPingEnabled,
draftResourcesEnabled, draftResourcesEnabled,
@@ -936,7 +1014,7 @@ export default function DataCollectionPage() {
<tbody className="divide-y divide-border"> <tbody className="divide-y divide-border">
{SCHEDULER_JOB_KEYS.map((jobKey) => { {SCHEDULER_JOB_KEYS.map((jobKey) => {
const j = schedulerJobsByKey[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 const en = fixedSchedule
? Boolean(j?.enabled ?? true) ? Boolean(j?.enabled ?? true)
: jobKey === "traffic" : jobKey === "traffic"
@@ -949,7 +1027,9 @@ export default function DataCollectionPage() {
? draftPingEnabled ? draftPingEnabled
: jobKey === "uptime_speed" : jobKey === "uptime_speed"
? draftSpeedEnabled ? draftSpeedEnabled
: draftInternetPathEnabled : jobKey === "certificates_renew"
? draftCertRenewEnabled
: draftInternetPathEnabled
const iv = fixedSchedule const iv = fixedSchedule
? String(j?.intervalSec ?? (jobKey === "gre_bgp" ? 30 : 20)) ? String(j?.intervalSec ?? (jobKey === "gre_bgp" ? 30 : 20))
: jobKey === "traffic" : jobKey === "traffic"
@@ -962,7 +1042,9 @@ export default function DataCollectionPage() {
? uptimeIntervalDraft ? uptimeIntervalDraft
: jobKey === "uptime_speed" : jobKey === "uptime_speed"
? uptimeSpeedIntervalDraft ? uptimeSpeedIntervalDraft
: internetPathIntervalDraft : jobKey === "certificates_renew"
? certRenewIntervalDraft
: internetPathIntervalDraft
const setIv = fixedSchedule const setIv = fixedSchedule
? () => {} ? () => {}
: jobKey === "traffic" : jobKey === "traffic"
@@ -975,7 +1057,9 @@ export default function DataCollectionPage() {
? setUptimeIntervalDraft ? setUptimeIntervalDraft
: jobKey === "uptime_speed" : jobKey === "uptime_speed"
? setUptimeSpeedIntervalDraft ? setUptimeSpeedIntervalDraft
: setInternetPathIntervalDraft : jobKey === "certificates_renew"
? setCertRenewIntervalDraft
: setInternetPathIntervalDraft
const defSec = fixedSchedule const defSec = fixedSchedule
? Number(j?.intervalSec ?? (jobKey === "gre_bgp" ? 30 : 20)) ? Number(j?.intervalSec ?? (jobKey === "gre_bgp" ? 30 : 20))
: jobKey === "traffic" : jobKey === "traffic"
@@ -988,7 +1072,9 @@ export default function DataCollectionPage() {
? 15 ? 15
: jobKey === "uptime_speed" : jobKey === "uptime_speed"
? 60 ? 60
: 300 : jobKey === "certificates_renew"
? 21600
: 300
return ( return (
<tr key={jobKey} className="hover:bg-muted/40"> <tr key={jobKey} className="hover:bg-muted/40">
<td className="px-5 py-3 align-top"> <td className="px-5 py-3 align-top">
@@ -1009,6 +1095,7 @@ export default function DataCollectionPage() {
else if (jobKey === "uptime_resources") setDraftResourcesEnabled(v) else if (jobKey === "uptime_resources") setDraftResourcesEnabled(v)
else if (jobKey === "uptime_ping") setDraftPingEnabled(v) else if (jobKey === "uptime_ping") setDraftPingEnabled(v)
else if (jobKey === "uptime_speed") setDraftSpeedEnabled(v) else if (jobKey === "uptime_speed") setDraftSpeedEnabled(v)
else if (jobKey === "certificates_renew") setDraftCertRenewEnabled(v)
else setDraftInternetPathEnabled(v) else setDraftInternetPathEnabled(v)
}} }}
/> />
@@ -1111,6 +1198,15 @@ export default function DataCollectionPage() {
inputMode="numeric" inputMode="numeric"
/> />
</div> </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> </div>
<p className="text-[11px] text-muted-foreground leading-snug"> <p className="text-[11px] text-muted-foreground leading-snug">
Для ping-проб у отдельных записей в мониторинге можно задать свой интервал (0 = глобальный «Интервал ping» в таблице). Для ping-проб у отдельных записей в мониторинге можно задать свой интервал (0 = глобальный «Интервал ping» в таблице).
@@ -1157,6 +1253,8 @@ export default function DataCollectionPage() {
retentionDays: uRet, retentionDays: uRet,
}), }),
}) })
const certRenewInt = Math.max(300, Number.parseInt(certRenewIntervalDraft, 10) || 21600)
const renewBeforeDays = Math.max(1, Math.min(90, Number.parseInt(renewBeforeDaysDraft, 10) || 30))
await apiFetch("/api/internet-path/settings", { await apiFetch("/api/internet-path/settings", {
method: "PUT", method: "PUT",
body: JSON.stringify({ body: JSON.stringify({
@@ -1164,6 +1262,14 @@ export default function DataCollectionPage() {
intervalSec: ipInt, intervalSec: ipInt,
}), }),
}) })
await apiFetch("/api/certificates/renew-settings", {
method: "PUT",
body: JSON.stringify({
enabled: draftCertRenewEnabled,
intervalSec: certRenewInt,
renewBeforeDays,
}),
})
await loadCollectors() await loadCollectors()
} catch (e) { } catch (e) {
setCollectorError(e instanceof Error ? e.message : "Не удалось сохранить") setCollectorError(e instanceof Error ? e.message : "Не удалось сохранить")
+46
View File
@@ -351,6 +351,7 @@ CREATE TABLE IF NOT EXISTS certificate_issue_jobs (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
status TEXT NOT NULL DEFAULT 'queued', status TEXT NOT NULL DEFAULT 'queued',
step TEXT NOT NULL DEFAULT 'queued', step TEXT NOT NULL DEFAULT 'queued',
source TEXT NOT NULL DEFAULT 'manual',
server_id TEXT NOT NULL, server_id TEXT NOT NULL,
cert_name TEXT NOT NULL, cert_name TEXT NOT NULL,
domain_names TEXT NOT NULL, domain_names TEXT NOT NULL,
@@ -362,6 +363,34 @@ CREATE TABLE IF NOT EXISTS certificate_issue_jobs (
error TEXT 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 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,
@@ -617,6 +646,23 @@ SELECT 1, 'https://acme-v02.api.letsencrypt.org/directory', '', '', ''
WHERE NOT EXISTS (SELECT 1 FROM acme_settings WHERE id = 1); 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(` sqlite.exec(`
INSERT INTO alert_engine_cursor (id, last_source_finished_at) INSERT INTO alert_engine_cursor (id, last_source_finished_at)
SELECT 1, NULL SELECT 1, NULL
+31
View File
@@ -300,6 +300,7 @@ export const certificateIssueJobs = sqliteTable("certificate_issue_jobs", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
status: text("status", { enum: ["queued", "running", "done", "failed"] }).notNull().default("queued"), status: text("status", { enum: ["queued", "running", "done", "failed"] }).notNull().default("queued"),
step: text("step").notNull().default("queued"), step: text("step").notNull().default("queued"),
source: text("source", { enum: ["manual", "scheduler"] }).notNull().default("manual"),
serverId: text("server_id").notNull(), serverId: text("server_id").notNull(),
certName: text("cert_name").notNull(), certName: text("cert_name").notNull(),
domainNames: text("domain_names").notNull(), domainNames: text("domain_names").notNull(),
@@ -311,6 +312,34 @@ export const certificateIssueJobs = sqliteTable("certificate_issue_jobs", {
error: text("error"), 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'))`),
})
/** Группы правил: ANY = хотя бы одно; ALL = все одновременно в окне тика. */ /** Группы правил: ANY = хотя бы одно; ALL = все одновременно в окне тика. */
export const alertGroups = sqliteTable("alert_groups", { export const alertGroups = sqliteTable("alert_groups", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
@@ -531,6 +560,8 @@ export type EvobgpSettingsRow = typeof evobgpSettings.$inferSelect
export type AlertTelegramSettingsRow = typeof alertTelegramSettings.$inferSelect export type AlertTelegramSettingsRow = typeof alertTelegramSettings.$inferSelect
export type AcmeSettingsRow = typeof acmeSettings.$inferSelect 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 BackupScheduleSettingsRow = typeof backupScheduleSettings.$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
+35 -80
View File
@@ -1,23 +1,21 @@
import { randomUUID } from "node:crypto" import { randomUUID } from "node:crypto"
import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises" import { readFile, rm } 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"
import { putBackupScheduleSettingsSchema } from "@mmapp/contracts/backups"
import { listServersRead } from "../modules/servers/service/servers-service.js" 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" import { appendEvent } from "../modules/events/service/events-service.js"
import { refreshScheduler } from "../services/scheduler.js"
type BackupMeta = { import {
id: string getBackupsDir,
serverId: string getBackupScheduleSettings,
serverName: string readBackupIndex,
filename: string runBackupForServer,
sizeBytes: number updateBackupScheduleSettings,
createdAt: string writeBackupIndex,
kind: "manual" type BackupMeta,
notes?: string } from "../services/backup-service.js"
}
type BackupJobStatus = "queued" | "running" | "done" | "failed" type BackupJobStatus = "queued" | "running" | "done" | "failed"
type BackupJob = { type BackupJob = {
@@ -32,36 +30,8 @@ type BackupJob = {
failures: Array<{ serverId: string; error: string }> 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>() 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({ const CreateBackupBodySchema = z.object({
serverIds: z.array(z.union([z.string(), z.number()])).min(1), serverIds: z.array(z.union([z.string(), z.number()])).min(1),
notes: z.string().max(500).optional(), notes: z.string().max(500).optional(),
@@ -74,42 +44,13 @@ const BackupJobIdParamSchema = z.object({
jobId: z.string().min(1), 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) { 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 readIndex() const indexRows = await readBackupIndex()
for (const id of ids) { for (const id of ids) {
try { try {
const meta = await runBackupForServer(id, notes) const meta = await runBackupForServer(id, "manual", notes)
indexRows.unshift(meta) indexRows.unshift(meta)
job.created.push(meta) job.created.push(meta)
} catch (err) { } catch (err) {
@@ -119,7 +60,7 @@ async function processBackupJob(job: BackupJob, ids: string[], notes?: string) {
job.completed += 1 job.completed += 1
} }
} }
await writeIndex(indexRows) await writeBackupIndex(indexRows)
job.status = "done" job.status = "done"
job.finishedAt = new Date().toISOString() job.finishedAt = new Date().toISOString()
appendEvent({ appendEvent({
@@ -141,11 +82,25 @@ 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 readIndex() const rows = await readBackupIndex()
rows.sort((a, b) => b.createdAt.localeCompare(a.createdAt)) rows.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
return reply.send(rows) return reply.send(rows)
}) })
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) => { app.post("/backups/create", { schema: { body: CreateBackupBodySchema } }, async (req, reply) => {
const inputIds = req.body.serverIds.map((x) => String(x)) const inputIds = req.body.serverIds.map((x) => String(x))
const notes = req.body.notes?.trim() || undefined const notes = req.body.notes?.trim() || undefined
@@ -216,10 +171,10 @@ 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 readIndex() const rows = await readBackupIndex()
const hit = rows.find((r) => r.id === 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(BACKUPS_DIR, 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)
if (content == null) return reply.status(404).send({ error: "Файл бэкапа не найден" }) if (content == null) return reply.status(404).send({ error: "Файл бэкапа не найден" })
reply.header("Content-Type", "text/plain; charset=utf-8") reply.header("Content-Type", "text/plain; charset=utf-8")
@@ -228,12 +183,12 @@ 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 readIndex() const rows = await readBackupIndex()
const idx = rows.findIndex((r) => r.id === req.params.id) const idx = rows.findIndex((r) => r.id === req.params.id)
if (idx < 0) return reply.status(404).send({ error: "Бэкап не найден" }) if (idx < 0) return reply.status(404).send({ error: "Бэкап не найден" })
const [hit] = rows.splice(idx, 1) const [hit] = rows.splice(idx, 1)
await writeIndex(rows) await writeBackupIndex(rows)
await rm(path.join(BACKUPS_DIR, hit.filename), { force: true }) await rm(path.join(getBackupsDir(), hit.filename), { force: true })
return reply.status(204).send() return reply.status(204).send()
}) })
} }
+22 -83
View File
@@ -3,101 +3,25 @@ import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { import {
certificateIssueRequestSchema, certificateIssueRequestSchema,
putAcmeCloudflareSettingsSchema, putAcmeCloudflareSettingsSchema,
putCertificateRenewSettingsSchema,
testAcmeCloudflareSettingsSchema, testAcmeCloudflareSettingsSchema,
} from "@mmapp/contracts/certificates" } from "@mmapp/contracts/certificates"
import { appendEvent } from "../modules/events/service/events-service.js" import { refreshScheduler } from "../services/scheduler.js"
import { issueCertificateWithCloudflareDns, testCloudflareToken } from "../services/acme-cloudflare.js" import { testCloudflareToken } from "../services/acme-cloudflare.js"
import { queueCertificateIssueJob } from "../services/certificate-issue-runner.js"
import { import {
createIssueJobRecord, createIssueJobRecord,
getAcmeCloudflareToken, getAcmeCloudflareToken,
getAcmeSettingsPublic, getAcmeSettingsPublic,
getCertificateRenewSettings,
getIssueJobRecord, getIssueJobRecord,
getServerRowByIdString, getServerRowByIdString,
listCertificatesFromServers, listCertificatesFromServers,
toIssueJobDto, toIssueJobDto,
updateAcmeSettings, updateAcmeSettings,
updateIssueJobRecord, updateCertificateRenewSettings,
} from "../services/certificates-service.js" } 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) => { const certificatesRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/certificates", async (_req, reply) => { app.get("/certificates", async (_req, reply) => {
return reply.send(await listCertificatesFromServers()) return reply.send(await listCertificatesFromServers())
@@ -119,6 +43,20 @@ const certificatesRoutes: FastifyPluginAsyncZod = async (app) => {
return reply.send(updateAcmeSettings(parsed.data)) 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) => { app.post("/certificates/acme-settings/test", async (req, reply) => {
const parsed = testAcmeCloudflareSettingsSchema.safeParse(req.body ?? {}) const parsed = testAcmeCloudflareSettingsSchema.safeParse(req.body ?? {})
if (!parsed.success) { if (!parsed.success) {
@@ -157,8 +95,9 @@ const certificatesRoutes: FastifyPluginAsyncZod = async (app) => {
domainNames: parsed.data.domainNames.map((d) => d.trim()).filter(Boolean), domainNames: parsed.data.domainNames.map((d) => d.trim()).filter(Boolean),
keyType: parsed.data.keyType ?? "rsa2048", keyType: parsed.data.keyType ?? "rsa2048",
trustStore, trustStore,
source: "manual",
}) })
queueMicrotask(() => { void runIssueJob(jobId) }) queueCertificateIssueJob(jobId)
return reply.send({ jobId }) return reply.send({ jobId })
}) })
@@ -0,0 +1,142 @@
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,
readBackupIndex,
resolveBackupServerIds,
runBackupForServer,
touchBackupScheduleRunMeta,
writeBackupIndex,
} 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)
const indexRows = await readBackupIndex()
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 {
const meta = await runBackupForServer(id, "auto")
indexRows.unshift(meta)
snapshot.created += 1
} catch (e) {
const message = e instanceof Error ? e.message : String(e)
snapshot.failures += 1
snapshot.errors?.push(`${id}: ${message}`)
}
}
await writeBackupIndex(indexRows)
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
}
}
+241
View File
@@ -0,0 +1,241 @@
import { randomUUID } from "node:crypto"
import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises"
import path from "node:path"
import { eq } from "drizzle-orm"
import type { BackupScheduleSettingsDto } from "@mmapp/contracts/backups"
import { db } from "../db/index.js"
import { 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")
const INDEX_PATH = path.join(BACKUPS_DIR, "index.json")
export type BackupMeta = {
id: string
serverId: string
serverName: string
filename: string
sizeBytes: number
createdAt: string
kind: "manual" | "auto"
notes?: string
}
export async function ensureBackupStorage(): Promise<void> {
await mkdir(BACKUPS_DIR, { recursive: true })
}
export async function readBackupIndex(): Promise<BackupMeta[]> {
await ensureBackupStorage()
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> {
await ensureBackupStorage()
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())}`
}
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 sameLocalSlot(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()
}
export function isBackupDue(now: Date, settings: BackupScheduleSettingsDto, lastRunAt: string | null | undefined): boolean {
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
}
if (now < slot) return false
if (lastRunAt) {
const prev = new Date(lastRunAt)
if (Number.isNaN(prev.getTime())) return true
if (settings.frequency === "daily" && sameLocalSlot(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
}
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 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,
notes,
}
}
export async function pruneBackupsForServer(serverId: string, keepCount: number): Promise<number> {
const rows = await readBackupIndex()
const forServer = rows.filter((r) => r.serverId === serverId)
if (forServer.length <= keepCount) return 0
const sorted = [...forServer].sort((a, b) => b.createdAt.localeCompare(a.createdAt))
const toDelete = sorted.slice(keepCount)
const deleteIds = new Set(toDelete.map((r) => r.id))
for (const hit of toDelete) {
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
}
export function getBackupsDir(): string {
return BACKUPS_DIR
}
export function getBackupIndexPath(): string {
return INDEX_PATH
}
@@ -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 { desc, eq, inArray } from "drizzle-orm"
import type { CertificateDto } from "@mmapp/contracts/certificates" import type { CertificateDto, CertificateRenewSettingsDto } from "@mmapp/contracts/certificates"
import { db } from "../db/index.js" 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 { mapRosCertificates } from "./certificate-parse.js"
import { MikrotikClient } from "./mikrotik.js" import { MikrotikClient } from "./mikrotik.js"
const SETTINGS_ID = 1 const SETTINGS_ID = 1
export type ManagedCertificateTarget = {
serverId: string
certName: string
domainNames: string[]
keyType: string
trustStore: string
finishedAt: string
}
export async function listCertificatesFromServers(): Promise<{ export async function listCertificatesFromServers(): Promise<{
certificates: CertificateDto[] certificates: CertificateDto[]
failures: Array<{ serverId: string; serverName?: string; error: string }> failures: Array<{ serverId: string; serverName?: string; error: string }>
@@ -109,12 +123,14 @@ export function createIssueJobRecord(input: {
domainNames: string[] domainNames: string[]
keyType: string keyType: string
trustStore: string trustStore: string
source?: "manual" | "scheduler"
}) { }) {
const now = new Date().toISOString() const now = new Date().toISOString()
db.insert(certificateIssueJobs).values({ db.insert(certificateIssueJobs).values({
id: input.id, id: input.id,
status: "queued", status: "queued",
step: "queued", step: "queued",
source: input.source ?? "manual",
serverId: input.serverId, serverId: input.serverId,
certName: input.certName, certName: input.certName,
domainNames: JSON.stringify(input.domainNames), domainNames: JSON.stringify(input.domainNames),
@@ -168,3 +184,103 @@ export function getServerRowByIdString(serverId: string) {
if (!Number.isFinite(id)) return null if (!Number.isFinite(id)) return null
return db.select().from(servers).where(eq(servers.id, id)).limit(1).all()[0] ?? 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, collectInternetPathSnapshotOnce,
getInternetPathSettings, getInternetPathSettings,
} from "./internet-path-collector.js" } 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 { import {
endSchedulerJob, endSchedulerJob,
isSchedulerJobRunning, isSchedulerJobRunning,
@@ -57,6 +61,8 @@ export const JOB_KEYS = [
"uptime_speed", "uptime_speed",
"internet_path", "internet_path",
"gre_bgp", "gre_bgp",
"certificates_renew",
"backups",
"alert_engine", "alert_engine",
] as const ] as const
export type SchedulerJobKey = (typeof JOB_KEYS)[number] export type SchedulerJobKey = (typeof JOB_KEYS)[number]
@@ -119,6 +125,12 @@ async function runSchedulerJobBody(jobKey: SchedulerJobKey): Promise<void> {
case "internet_path": case "internet_path":
snapshot = await collectInternetPathSnapshotOnce() snapshot = await collectInternetPathSnapshotOnce()
break break
case "certificates_renew":
snapshot = await collectCertificatesRenewOnce()
break
case "backups":
snapshot = await collectScheduledBackupsOnce()
break
case "alert_engine": { case "alert_engine": {
const r = await runAlertEngineOnce() const r = await runAlertEngineOnce()
snapshot = { snapshot = {
@@ -322,6 +334,30 @@ export function refreshScheduler(): void {
}, greBgpMs), }, 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 const alertMs = 20_000
void executeSchedulerJob("alert_engine").catch(() => {}) void executeSchedulerJob("alert_engine").catch(() => {})
timers.set( timers.set(
@@ -353,6 +389,8 @@ export function getSchedulerStatus() {
const uptime = getUptimeSettings() const uptime = getUptimeSettings()
const apiPing = getServersApiPingSettings() const apiPing = getServersApiPingSettings()
const internetPath = getInternetPathSettings() const internetPath = getInternetPathSettings()
const certRenew = getCertificateRenewSettings()
const backupSchedule = getBackupScheduleSettings()
const resOn = uptime.resourcesEnabled ?? uptime.enabled const resOn = uptime.resourcesEnabled ?? uptime.enabled
const pingOn = uptime.pingEnabled ?? uptime.enabled const pingOn = uptime.pingEnabled ?? uptime.enabled
@@ -366,6 +404,8 @@ export function getSchedulerStatus() {
uptime_speed: { enabled: spdOn, intervalSec: uptime.speedIntervalSec }, uptime_speed: { enabled: spdOn, intervalSec: uptime.speedIntervalSec },
internet_path: { enabled: internetPath.enabled, intervalSec: internetPath.intervalSec }, internet_path: { enabled: internetPath.enabled, intervalSec: internetPath.intervalSec },
gre_bgp: { enabled: true, intervalSec: 30 }, 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 }, alert_engine: { enabled: true, intervalSec: 20 },
} }
@@ -192,6 +192,40 @@ export interface InternetPathRunSnapshot {
fatalError?: string 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 = export type SchedulerRunSnapshot =
| TrafficRunSnapshot | TrafficRunSnapshot
| ResourcesRunSnapshot | ResourcesRunSnapshot
@@ -200,4 +234,6 @@ export type SchedulerRunSnapshot =
| ServersRestPingRunSnapshot | ServersRestPingRunSnapshot
| GreBgpSnapshotRunSnapshot | GreBgpSnapshotRunSnapshot
| InternetPathRunSnapshot | InternetPathRunSnapshot
| CertificatesRenewRunSnapshot
| BackupsRunSnapshot
| AlertEngineRunSnapshot | 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
+90
View File
@@ -1,4 +1,94 @@
[ [
{
"id": "bec5c501-6d3f-4921-9774-5f0e3d526d87",
"serverId": "6",
"serverName": "ihor.msk.rt.shx.su",
"filename": "ihor.msk.rt.shx.su_2026-05-12_21-45-18.rsc",
"sizeBytes": 711251,
"createdAt": "2026-05-12T14:45:18.144Z",
"kind": "auto"
},
{
"id": "2c1d9c24-b612-4756-8352-afd127b2fa81",
"serverId": "5",
"serverName": "servhost.nsk.rt.shx.su",
"filename": "servhost.nsk.rt.shx.su_2026-05-12_21-45-14.rsc",
"sizeBytes": 707990,
"createdAt": "2026-05-12T14:45:14.994Z",
"kind": "auto"
},
{
"id": "3eac2f70-a5e8-428c-a50d-59223061827b",
"serverId": "4",
"serverName": "veesp.swe.rt.shx.su",
"filename": "veesp.swe.rt.shx.su_2026-05-12_21-45-08.rsc",
"sizeBytes": 2195,
"createdAt": "2026-05-12T14:45:08.312Z",
"kind": "auto"
},
{
"id": "2c600052-70ff-4525-910d-725d0f4d2cfb",
"serverId": "3",
"serverName": "vpsville.msk.rt.shx.su",
"filename": "vpsville.msk.rt.shx.su_2026-05-12_21-45-07.rsc",
"sizeBytes": 710694,
"createdAt": "2026-05-12T14:45:07.786Z",
"kind": "auto"
},
{
"id": "b97be1bb-9c72-44a8-be27-333ddb58ca24",
"serverId": "2",
"serverName": "Gateway",
"filename": "Gateway_2026-05-12_21-45-05.rsc",
"sizeBytes": 1712337,
"createdAt": "2026-05-12T14:45:05.250Z",
"kind": "auto"
},
{
"id": "891217c8-faa2-40d7-b093-e325f41d8a3d",
"serverId": "6",
"serverName": "ihor.msk.rt.shx.su",
"filename": "ihor.msk.rt.shx.su_2026-05-12_21-44-21.rsc",
"sizeBytes": 711251,
"createdAt": "2026-05-12T14:44:21.828Z",
"kind": "auto"
},
{
"id": "f6bc0abc-c783-4d6f-92b6-d0cc489e6772",
"serverId": "5",
"serverName": "servhost.nsk.rt.shx.su",
"filename": "servhost.nsk.rt.shx.su_2026-05-12_21-44-17.rsc",
"sizeBytes": 707990,
"createdAt": "2026-05-12T14:44:17.008Z",
"kind": "auto"
},
{
"id": "3f8b71df-5879-4fda-98f9-316353db3657",
"serverId": "4",
"serverName": "veesp.swe.rt.shx.su",
"filename": "veesp.swe.rt.shx.su_2026-05-12_21-44-14.rsc",
"sizeBytes": 2195,
"createdAt": "2026-05-12T14:44:14.981Z",
"kind": "auto"
},
{
"id": "0431c73a-a2a2-4100-8940-a7e560649f53",
"serverId": "3",
"serverName": "vpsville.msk.rt.shx.su",
"filename": "vpsville.msk.rt.shx.su_2026-05-12_21-44-14.rsc",
"sizeBytes": 710694,
"createdAt": "2026-05-12T14:44:14.434Z",
"kind": "auto"
},
{
"id": "523315c7-4db9-4e09-8bfb-3f36dd692485",
"serverId": "2",
"serverName": "Gateway",
"filename": "Gateway_2026-05-12_21-44-11.rsc",
"sizeBytes": 1712337,
"createdAt": "2026-05-12T14:44:11.677Z",
"kind": "auto"
},
{ {
"id": "b820bdc8-d184-4615-92e1-40df3872d554", "id": "b820bdc8-d184-4615-92e1-40df3872d554",
"serverId": "2", "serverId": "2",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,45 @@
# synthetic export generated by MikrotikManager
# generated-at: 2026-05-12T14:44:14.588Z
/system identity
set name="veesp.swe.rt.shx.su"
/interface
:put "interface name="ether1" mtu=1500 disabled=no"
:put "interface name="MSK-DC" mtu=1350 disabled=no"
:put "interface name="MSK-IHOR" mtu=1400 disabled=no"
:put "interface name="MSK-VPSVILLE" mtu=1400 disabled=no"
:put "interface name="NSK-SERVHOST" mtu=1400 disabled=no"
:put "interface name="br1" mtu=1500 disabled=no"
:put "interface name="gre-tunnel1" mtu=1450 disabled=no"
:put "interface name="lo" mtu=65536 disabled=no"
:put "interface name="wg-tunnel" mtu=1420 disabled=no"
:put "interface name="wg1" mtu=1280 disabled=no"
/ip address
add address=62.182.194.146/24 interface="ether1"
add address=10.200.100.26/30 interface="MSK-IHOR"
add address=10.200.200.2/30 interface="*5"
add address=10.200.40.2/30 interface="MSK-VPSVILLE"
add address=10.205.1.2/30 interface="wg1"
add address=10.200.100.2/30 interface="*11" comment="Interface: MSK-SELECTEL (GRE) to selectel.msk.rt.shx.su"
add address=10.200.100.38/30 interface="MSK-DC" comment="Interface: MSK-DC (GRE) to dc.msk.rt.shx.su"
add address=10.200.100.14/30 interface="MSK-VPSVILLE" comment="Interface: MSK-VPSVILLE (GRE) to vpsville.msk.rt.shx.su"
add address=10.101.1.2/30 interface="gre-tunnel1"
add address=10.200.100.54/30 interface="NSK-SERVHOST" comment="Interface: NSK-SERVHOST (GRE) to servhost.nsk.rt.shx.su"
/ip route
add dst-address=192.168.0.0/16 gateway=10.200.100.13%MSK-VPSVILLE distance=1
add dst-address=0.0.0.0/0 gateway=62.182.194.1 distance=1
add dst-address=10.101.1.0/30 gateway=gre-tunnel1 distance=0
add dst-address=10.200.100.12/30 gateway=MSK-VPSVILLE distance=0
add dst-address=10.200.100.24/30 gateway=MSK-IHOR distance=0
add dst-address=10.200.100.36/30 gateway=MSK-DC distance=0
add dst-address=10.200.100.52/30 gateway=NSK-SERVHOST distance=0
add dst-address=10.205.1.0/30 gateway=wg1 distance=0
add dst-address=62.182.194.0/24 gateway=br1 distance=0
add dst-address=192.168.0.0/16 gateway=10.200.100.53 distance=1
/ip firewall filter
add chain=input action=drop protocol=udp dst-port=53
add chain=input action=accept protocol=udp dst-port=13231
@@ -0,0 +1,45 @@
# synthetic export generated by MikrotikManager
# generated-at: 2026-05-12T14:45:07.934Z
/system identity
set name="veesp.swe.rt.shx.su"
/interface
:put "interface name="ether1" mtu=1500 disabled=no"
:put "interface name="MSK-DC" mtu=1350 disabled=no"
:put "interface name="MSK-IHOR" mtu=1400 disabled=no"
:put "interface name="MSK-VPSVILLE" mtu=1400 disabled=no"
:put "interface name="NSK-SERVHOST" mtu=1400 disabled=no"
:put "interface name="br1" mtu=1500 disabled=no"
:put "interface name="gre-tunnel1" mtu=1450 disabled=no"
:put "interface name="lo" mtu=65536 disabled=no"
:put "interface name="wg-tunnel" mtu=1420 disabled=no"
:put "interface name="wg1" mtu=1280 disabled=no"
/ip address
add address=62.182.194.146/24 interface="ether1"
add address=10.200.100.26/30 interface="MSK-IHOR"
add address=10.200.200.2/30 interface="*5"
add address=10.200.40.2/30 interface="MSK-VPSVILLE"
add address=10.205.1.2/30 interface="wg1"
add address=10.200.100.2/30 interface="*11" comment="Interface: MSK-SELECTEL (GRE) to selectel.msk.rt.shx.su"
add address=10.200.100.38/30 interface="MSK-DC" comment="Interface: MSK-DC (GRE) to dc.msk.rt.shx.su"
add address=10.200.100.14/30 interface="MSK-VPSVILLE" comment="Interface: MSK-VPSVILLE (GRE) to vpsville.msk.rt.shx.su"
add address=10.101.1.2/30 interface="gre-tunnel1"
add address=10.200.100.54/30 interface="NSK-SERVHOST" comment="Interface: NSK-SERVHOST (GRE) to servhost.nsk.rt.shx.su"
/ip route
add dst-address=192.168.0.0/16 gateway=10.200.100.13%MSK-VPSVILLE distance=1
add dst-address=0.0.0.0/0 gateway=62.182.194.1 distance=1
add dst-address=10.101.1.0/30 gateway=gre-tunnel1 distance=0
add dst-address=10.200.100.12/30 gateway=MSK-VPSVILLE distance=0
add dst-address=10.200.100.24/30 gateway=MSK-IHOR distance=0
add dst-address=10.200.100.36/30 gateway=MSK-DC distance=0
add dst-address=10.200.100.52/30 gateway=NSK-SERVHOST distance=0
add dst-address=10.205.1.0/30 gateway=wg1 distance=0
add dst-address=62.182.194.0/24 gateway=br1 distance=0
add dst-address=192.168.0.0/16 gateway=10.200.100.53 distance=1
/ip firewall filter
add chain=input action=drop protocol=udp dst-port=53
add chain=input action=accept protocol=udp dst-port=13231
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+27
View File
@@ -72,6 +72,31 @@ export interface InternetPathRunSnapshot {
fatalError?: string 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 = export type SchedulerRunSnapshot =
| TrafficRunSnapshot | TrafficRunSnapshot
| ResourcesRunSnapshot | ResourcesRunSnapshot
@@ -80,6 +105,8 @@ export type SchedulerRunSnapshot =
| ServersRestPingRunSnapshot | ServersRestPingRunSnapshot
| GreBgpSnapshotRunSnapshot | GreBgpSnapshotRunSnapshot
| InternetPathRunSnapshot | InternetPathRunSnapshot
| CertificatesRenewRunSnapshot
| BackupsRunSnapshot
| AlertEngineRunSnapshot | AlertEngineRunSnapshot
export interface TrafficServerSnapshot { export interface TrafficServerSnapshot {
+8
View File
@@ -9,6 +9,8 @@ export const SCHEDULER_JOB_KEYS = [
"uptime_speed", "uptime_speed",
"internet_path", "internet_path",
"gre_bgp", "gre_bgp",
"certificates_renew",
"backups",
"alert_engine", "alert_engine",
] as const ] as const
export type SchedulerJobKey = (typeof SCHEDULER_JOB_KEYS)[number] export type SchedulerJobKey = (typeof SCHEDULER_JOB_KEYS)[number]
@@ -21,6 +23,8 @@ export const SCHEDULER_JOB_LABELS: Record<string, string> = {
uptime_speed: "Uptime: speed", uptime_speed: "Uptime: speed",
internet_path: "Internet Path", internet_path: "Internet Path",
gre_bgp: "GRE + BGP", gre_bgp: "GRE + BGP",
certificates_renew: "Сертификаты: автообновление",
backups: "Бэкапы",
alert_engine: "Оповещения", alert_engine: "Оповещения",
} }
@@ -35,6 +39,10 @@ export const SCHEDULER_JOB_DESCRIPTIONS: Record<string, string> = {
internet_path: "Снимок данных для карты интернет-маршрута на dashboard (WAN/JH/EN, route+runtime, speed-пробы).", internet_path: "Снимок данных для карты интернет-маршрута на dashboard (WAN/JH/EN, route+runtime, speed-пробы).",
gre_bgp: gre_bgp:
"Опрос GRE-туннелей и BGP-сессий на включённых серверах, запись сэмплов в SQLite для движка оповещений.", "Опрос GRE-туннелей и BGP-сессий на включённых серверах, запись сэмплов в SQLite для движка оповещений.",
certificates_renew:
"Проверка сертификатов, выпущенных через UI, и автообновление через ACME DNS-01 (Cloudflare) до истечения срока.",
backups:
"Плановые бэкапы RouterOS по расписанию со страницы «Бэкапы»; тик планировщика раз в минуту.",
alert_engine: alert_engine:
"Оценка правил по данным из SQLite (сэмплы пишут джобы сбора, в т.ч. «GRE + BGP» и «Серверы: REST API»).", "Оценка правил по данным из SQLite (сэмплы пишут джобы сбора, в т.ч. «GRE + BGP» и «Серверы: REST API»).",
} }
+4
View File
@@ -29,6 +29,10 @@
"./certificates": { "./certificates": {
"types": "./dist/certificates.d.ts", "types": "./dist/certificates.d.ts",
"default": "./dist/certificates.js" "default": "./dist/certificates.js"
},
"./backups": {
"types": "./dist/backups.d.ts",
"default": "./dist/backups.js"
} }
}, },
"dependencies": { "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(), 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 CertificateDto = z.infer<typeof certificateDtoSchema>
export type CertificatesListResponse = z.infer<typeof certificatesListResponseSchema> export type CertificatesListResponse = z.infer<typeof certificatesListResponseSchema>
export type CertificateIssueRequest = z.infer<typeof certificateIssueRequestSchema> export type CertificateIssueRequest = z.infer<typeof certificateIssueRequestSchema>
export type CertificateIssueJob = z.infer<typeof certificateIssueJobSchema> export type CertificateIssueJob = z.infer<typeof certificateIssueJobSchema>
export type AcmeCloudflareSettingsDto = z.infer<typeof acmeCloudflareSettingsDtoSchema> 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 "./alerts.js"
export * from "./events.js" export * from "./events.js"
export * from "./certificates.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 { requestJson } from "@/shared/api/http-client"
import type { BackupScheduleSettingsDto } from "@mmapp/contracts/backups"
export type BackupItem = { export type BackupItem = {
id: string id: string
@@ -7,7 +8,7 @@ export type BackupItem = {
filename: string filename: string
sizeBytes: number sizeBytes: number
createdAt: string createdAt: string
kind: "manual" kind: "manual" | "auto"
notes?: string 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> { export async function deleteBackup(baseUrl: string, id: string): Promise<void> {
await requestJson<void>(baseUrl, `/api/backups/${id}`, { method: "DELETE" }) 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, AcmeCloudflareSettingsDto,
CertificateIssueJob, CertificateIssueJob,
CertificateIssueRequest, CertificateIssueRequest,
CertificateRenewSettingsDto,
CertificatesListResponse, CertificatesListResponse,
} from "@mmapp/contracts/certificates" } from "@mmapp/contracts/certificates"
import { requestJson } from "@/shared/api/http-client" import { requestJson } from "@/shared/api/http-client"
@@ -58,3 +59,21 @@ export async function testAcmeSettings(
body: JSON.stringify(payload ?? {}), 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),
})
}
+1 -1
View File
File diff suppressed because one or more lines are too long