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
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:
@@ -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>
|
||||
|
||||
@@ -31,6 +31,8 @@ import {
|
||||
type AlertEngineRunSnapshot,
|
||||
type GreBgpSnapshotRunSnapshot,
|
||||
type InternetPathRunSnapshot,
|
||||
type CertificatesRenewRunSnapshot,
|
||||
type BackupsRunSnapshot,
|
||||
type PingRunSnapshot,
|
||||
type ResourcesRunSnapshot,
|
||||
type SchedulerRunSnapshot,
|
||||
@@ -464,6 +466,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"]) => {
|
||||
@@ -683,6 +752,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,11 +786,12 @@ 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 [traffic, serversApi, uptime, internetPath, certRenew, runsRes] = await Promise.all([
|
||||
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)
|
||||
@@ -740,6 +813,9 @@ export default function DataCollectionPage() {
|
||||
setDraftSpeedEnabled(!!(uptime.speedEnabled ?? uptime.enabled))
|
||||
setDraftInternetPathEnabled(!!internetPath.enabled)
|
||||
setInternetPathIntervalDraft(String(internetPath.intervalSec ?? 300))
|
||||
setDraftCertRenewEnabled(!!certRenew.enabled)
|
||||
setCertRenewIntervalDraft(String(certRenew.intervalSec ?? 21600))
|
||||
setRenewBeforeDaysDraft(String(certRenew.renewBeforeDays ?? 30))
|
||||
} catch (e) {
|
||||
setCollectorError(e instanceof Error ? e.message : "Не удалось загрузить данные")
|
||||
} finally {
|
||||
@@ -781,8 +857,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,
|
||||
@@ -936,7 +1014,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 +1027,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 +1042,9 @@ export default function DataCollectionPage() {
|
||||
? uptimeIntervalDraft
|
||||
: jobKey === "uptime_speed"
|
||||
? uptimeSpeedIntervalDraft
|
||||
: internetPathIntervalDraft
|
||||
: jobKey === "certificates_renew"
|
||||
? certRenewIntervalDraft
|
||||
: internetPathIntervalDraft
|
||||
const setIv = fixedSchedule
|
||||
? () => {}
|
||||
: jobKey === "traffic"
|
||||
@@ -975,7 +1057,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 +1072,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 +1095,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 +1198,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» в таблице).
|
||||
@@ -1157,6 +1253,8 @@ export default function DataCollectionPage() {
|
||||
retentionDays: uRet,
|
||||
}),
|
||||
})
|
||||
const certRenewInt = Math.max(300, Number.parseInt(certRenewIntervalDraft, 10) || 21600)
|
||||
const renewBeforeDays = Math.max(1, Math.min(90, Number.parseInt(renewBeforeDaysDraft, 10) || 30))
|
||||
await apiFetch("/api/internet-path/settings", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
@@ -1164,6 +1262,14 @@ export default function DataCollectionPage() {
|
||||
intervalSec: ipInt,
|
||||
}),
|
||||
})
|
||||
await apiFetch("/api/certificates/renew-settings", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
enabled: draftCertRenewEnabled,
|
||||
intervalSec: certRenewInt,
|
||||
renewBeforeDays,
|
||||
}),
|
||||
})
|
||||
await loadCollectors()
|
||||
} catch (e) {
|
||||
setCollectorError(e instanceof Error ? e.message : "Не удалось сохранить")
|
||||
|
||||
Reference in New Issue
Block a user