Етап 7: планувальник бекапів за cron
Збір конфігів перестав залежати від того, чи згадає людина натиснути кнопку. Свій парсер cron (internal/cronx) замість залежності: бітові маски uint64 на поле, пошук наступного запуску покроково по хвилинах із запобіжником у чотири роки. Правило dom/dow — об'єднання, як у справжнього cron. Неможливий розклад (30 лютого) чесно відмовляє замість зациклення. Планувальник тікає раз на хвилину під advisory-блокуванням, тож у кластері розклад розкручує рівно один екземпляр. Спершу переноситься next_backup_at, потім ставиться завдання: падіння між кроками коштує одного пропущеного бекапу, зворотний порядок дав би нескінченну чергу. Форма розкладу: чотири пресети плюс довільний cron. Виправлено помилку, через яку пункт «свій розклад…» нічого не робив — обробник select відсікав порожнє значення, тобто саме той випадок, заради якого пункт існує. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
cdf5f1fb54
commit
436eff343d
13 changed files with 1163 additions and 18 deletions
83
HISTORY.md
83
HISTORY.md
|
|
@ -1299,7 +1299,82 @@ diff @@ −8,3 +8,4 @@, один доданий рядок «new-c
|
|||
|
||||
### Чого ще немає
|
||||
|
||||
Планувальника за `cron` — збір лише вручну. Тригера за Syslog.
|
||||
Git-двигуна: `commit_sha` поки дорівнює хешу вмісту, справжнього
|
||||
репозиторію немає. Порівняння з довільною версією в UI (API вміє через
|
||||
`?from=`, кнопки немає).
|
||||
Тригера за Syslog. Git-двигуна: `commit_sha` поки дорівнює хешу вмісту,
|
||||
справжнього репозиторію немає. Порівняння з довільною версією в UI (API
|
||||
вміє через `?from=`, кнопки немає).
|
||||
|
||||
---
|
||||
|
||||
## Планувальник бекапів за cron
|
||||
|
||||
Збір конфігів працював, але лише руками. Це не NCM: сенс бекапу в
|
||||
тому, що він робиться сам, а «зайти й натиснути» — це та сама
|
||||
відсутність бекапу, просто з кращим самопочуттям.
|
||||
|
||||
### Свій парсер cron замість бібліотеки
|
||||
|
||||
`robfig/cron` зробив би це за десять рядків, але тягнув би залежність
|
||||
заради того, що вміщується в одному файлі й не змінюється з 1975 року.
|
||||
Розбір — у бітові маски `uint64` на поле, пошук наступного запуску —
|
||||
покроково по хвилинах із запобіжником у чотири роки: перебір дешевший
|
||||
за арифметику з календарем і не має її крайніх випадків.
|
||||
|
||||
**Правило dom/dow — об'єднання, не перетин.** Якщо задані і день
|
||||
місяця, і день тижня, підходить збіг за будь-яким. Це виглядає
|
||||
неочікувано, доки не спробуєш записати «щоп'ятниці та першого числа» —
|
||||
іншого способу немає.
|
||||
|
||||
**Неможливий розклад чесно відмовляє.** `0 0 30 2 *` — 30 лютого не
|
||||
буває; запобіжник у чотири роки перетворює це на помилку, а не на
|
||||
зациклений процес. 29 лютого при цьому знаходиться правильно (2028).
|
||||
|
||||
11 тестів на парсер пройшли з першого запуску.
|
||||
|
||||
### Планувальник
|
||||
|
||||
Тік раз на хвилину — дрібніше cron не буває. Advisory-блокування
|
||||
(`0x6e70_6263`, «npbc»), бо інакше кожен екземпляр сервера в кластері
|
||||
поставив би своє завдання на той самий хост.
|
||||
|
||||
**Порядок кроків важливий: спершу перенести `next_backup_at`, потім
|
||||
ставити завдання.** Падіння між кроками коштує одного пропущеного
|
||||
бекапу. Зворотний порядок дав би нескінченну чергу однакових завдань —
|
||||
а це кладе і пристрій, і сервер.
|
||||
|
||||
**Некоректний cron відсувається на добу, а не пишеться в журнал
|
||||
щохвилини.** Скарга має лишитись помітною, але не перетворитись на шум,
|
||||
у якому потонуть справжні помилки.
|
||||
|
||||
**`next_backup_at IS NULL` = «час настав».** Так виглядає щойно
|
||||
збережена політика. З цієї ж причини будь-яка зміна політики обнуляє
|
||||
позначку: розклад міг стати частішим, і чекати за старим було б
|
||||
неправильно.
|
||||
|
||||
### Форма
|
||||
|
||||
Чотири готові розклади покривають майже все, що справді налаштовують;
|
||||
довільний cron лишився, але не першим, що бачить людина.
|
||||
|
||||
**Знайдена й виправлена помилка:** вибір «свій розклад…» нічого не
|
||||
робив. Обробник `select` мав вигляд `e.target.value && setCron(...)`, а
|
||||
значення цього пункту — порожній рядок, тож умова відсікала саме той
|
||||
випадок, заради якого пункт існує. Режим тепер тримається окремим
|
||||
прапорцем, а не виводиться з виразу: інакше поле не відкривалось би й
|
||||
тоді, коли власний вираз випадково збігся з пресетом.
|
||||
|
||||
### Перевірено наживо
|
||||
|
||||
Розклад `* * * * *` на stand-host:
|
||||
|
||||
```
|
||||
trigger | status | створено
|
||||
schedule | unchanged | 09:39:08
|
||||
наступний | останній
|
||||
09:40:00 | 09:20:31
|
||||
```
|
||||
|
||||
У браузері: вибір «свій розклад…» відкриває поле з поточним виразом;
|
||||
`0 99 * * *` відхиляється сервером із текстом «розклад: години: 99 поза
|
||||
межами 0..23» і модалка лишається відкритою; `*/30 * * * *`
|
||||
зберігається, і заголовок хоста стає «за розкладом */30 * * * *».
|
||||
Розклад повернуто на `0 3 * * *`.
|
||||
|
|
|
|||
14
ROADMAP.md
14
ROADMAP.md
|
|
@ -160,10 +160,11 @@ JSONPath, `discard unchanged`) — на сервері при записі: ін
|
|||
## Етап 7. NCM — збір конфігів до кінця — ⚠️ наполовину, 2026-08-16
|
||||
|
||||
> **Зроблено:** агентський модуль SSH/Telnet, черга завдань, диспетчер,
|
||||
> вивантаження зі стисненням і дедуплікацією. Перевірено наскрізно.
|
||||
> вивантаження зі стисненням і дедуплікацією, візуальний diff у вебі,
|
||||
> планувальник за cron. Перевірено наскрізно.
|
||||
>
|
||||
> **Лишилось:** планувальник за cron, тригер за Syslog, Git-двигун,
|
||||
> візуальний diff у вебі.
|
||||
> **Лишилось:** тригер за Syslog, Git-двигун, порівняння з довільною
|
||||
> версією в UI.
|
||||
|
||||
## Етап 7 (початковий план)
|
||||
|
||||
|
|
@ -176,9 +177,10 @@ JSONPath, `discard unchanged`) — на сервері при записі: ін
|
|||
регуляркою, віддає сирий текст чанками. Для `bdcom-olt` і `mikrotik-routeros`
|
||||
профілі вже в сіді — вони й стануть першими підопічними.
|
||||
|
||||
**Планувальник на сервері.** Cron із `ncm.device_policies.cron` плюс тригер за
|
||||
Syslog-подією (`on_syslog`, `%SYS-5-CONFIG_I`) — обидва поля в схемі є, читати їх
|
||||
нікому. Видає `ConfigJob` у живу сесію зонда.
|
||||
**Планувальник на сервері.** ✅ Cron із `ncm.device_policies.cron` — власний
|
||||
парсер `internal/cronx`, тік раз на хвилину під advisory-блокуванням. Лишився
|
||||
тригер за Syslog-подією (`on_syslog`, `%SYS-5-CONFIG_I`) — поле в схемі є,
|
||||
читати його нікому.
|
||||
|
||||
**Git-двигун.** `go-git` замість libgit2 — **чистий Go, без cgo**, що зберігає
|
||||
статичний бінарник без залежностей. Комітить у `refs/heads/device/<id>`, віддає
|
||||
|
|
|
|||
|
|
@ -529,6 +529,9 @@ Qtech, ZTE, BDCOM та інші.
|
|||
| `GET` | `/api/v1/devices/{id}/configs` | версії конфігу |
|
||||
| `GET` | `/api/v1/configs/{id}` | текст версії |
|
||||
| `GET` | `/api/v1/configs/{id}/diff` | порівняння з попередньою (`?from=…`) |
|
||||
| `GET` | `/api/v1/devices/{id}/backup-policy` | розклад бекапу хоста |
|
||||
| `PUT` | `/api/v1/devices/{id}/backup-policy` | змінити розклад (`ncm:write`) |
|
||||
| `GET` | `/api/v1/ncm-profiles` | довідник профілів для форми |
|
||||
|
||||
Ланцюг такий: REST кладе рядок у `ncm.jobs` зі станом `queued` →
|
||||
диспетчер усередині AgentService забирає його, якщо потрібний зонд на
|
||||
|
|
@ -546,6 +549,48 @@ LOCKED`: два екземпляри AgentService не надішлють одн
|
|||
відсутність результату, і окремий статус потрібен, щоб у журналі було
|
||||
видно, коли конфіг востаннє **справді** мінявся.
|
||||
|
||||
### Розклад
|
||||
|
||||
`PUT /api/v1/devices/{id}/backup-policy` приймає:
|
||||
|
||||
```json
|
||||
{
|
||||
"enabled": true,
|
||||
"cron": "0 3 * * *",
|
||||
"profile_id": "",
|
||||
"credential_id": ""
|
||||
}
|
||||
```
|
||||
|
||||
Порожні `profile_id` і `credential_id` означають «підібрати
|
||||
автоматично»: профіль — за виробником хоста, доступ — прив'язаний до
|
||||
хоста. Явно заданий завжди виграє: так лікується прошивка, що
|
||||
поводиться не як решта родини.
|
||||
|
||||
**Вираз cron розбирається власним парсером** (`internal/cronx`),
|
||||
п'ятипольовий, як у crontab: хвилина, година, день місяця, місяць,
|
||||
день тижня. Підтримані `*`, списки `1,2`, діапазони `8-20`, кроки
|
||||
`*/15` і `8-20/4`, назви місяців і днів (`jan`, `mon`), неділя і як
|
||||
`0`, і як `7`. Якщо задані одночасно день місяця й день тижня,
|
||||
підходить збіг за **будь-яким** — це правило самого cron, інакше
|
||||
«щоп'ятниці та першого числа» не записати.
|
||||
|
||||
Некоректний вираз відхиляється одразу з `400 bad_cron` і людським
|
||||
поясненням (`розклад: години: 99 поза межами 0..23`) — краще сказати це
|
||||
у формі, ніж мовчки не робити бекапів.
|
||||
|
||||
Планувальник усередині AgentService прокидається раз на хвилину (cron
|
||||
дрібніший за хвилину не буває) під advisory-блокуванням, тож у кластері
|
||||
розклад розкручує рівно один екземпляр. Порядок кроків — спершу
|
||||
перенести `next_backup_at`, потім поставити завдання: падіння між ними
|
||||
коштує одного пропущеного бекапу, а зворотний порядок дав би
|
||||
нескінченну чергу однакових завдань.
|
||||
|
||||
`next_backup_at IS NULL` вважається «час настав»: так виглядає щойно
|
||||
збережена політика, і чекати добу до першого бекапу означало б не
|
||||
зробити його тоді, коли він найпотрібніший. З цієї ж причини будь-яка
|
||||
зміна політики обнуляє позначку.
|
||||
|
||||
Зависле в `running` завдання повертається у відмову за десять хвилин:
|
||||
зонд міг зникнути разом із ним, і без цього хост лишився б без бекапів
|
||||
назавжди.
|
||||
|
|
|
|||
225
server/internal/cronx/cron.go
Normal file
225
server/internal/cronx/cron.go
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
// Package cronx — розбір розкладу у форматі cron і обчислення
|
||||
// наступного запуску.
|
||||
//
|
||||
// Власна реалізація на п'ять полів. Бібліотеки вміють ще й секунди,
|
||||
// роки, «@every 5m» і власні часові пояси — а тут потрібне рівно те,
|
||||
// що людина пише в полі «розклад бекапу», і жодного способу
|
||||
// прострелити собі ногу нестандартним синтаксисом.
|
||||
package cronx
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Schedule — розібраний розклад.
|
||||
//
|
||||
// Кожне поле — множина дозволених значень. Бітова маска замість
|
||||
// переліку: перевірка «чи підходить хвилина» стає одним зсувом, а
|
||||
// шукати наступний запуск доводиться перебором хвилин.
|
||||
type Schedule struct {
|
||||
minute uint64 // 0..59
|
||||
hour uint64 // 0..23
|
||||
dom uint64 // 1..31
|
||||
month uint64 // 1..12
|
||||
dow uint64 // 0..6, неділя = 0
|
||||
// domRestricted і dowRestricted кажуть, чи задане поле явно.
|
||||
// Потрібні для правила cron: якщо обидва задані, збіг за будь-яким
|
||||
// із них уже підходить. Це не примха, а спосіб записати «щоп'ятниці
|
||||
// та 1-го числа» одним рядком.
|
||||
domRestricted bool
|
||||
dowRestricted bool
|
||||
}
|
||||
|
||||
type fieldSpec struct {
|
||||
min, max int
|
||||
names map[string]int
|
||||
}
|
||||
|
||||
var (
|
||||
minuteSpec = fieldSpec{0, 59, nil}
|
||||
hourSpec = fieldSpec{0, 23, nil}
|
||||
domSpec = fieldSpec{1, 31, nil}
|
||||
monthSpec = fieldSpec{1, 12, map[string]int{
|
||||
"jan": 1, "feb": 2, "mar": 3, "apr": 4, "may": 5, "jun": 6,
|
||||
"jul": 7, "aug": 8, "sep": 9, "oct": 10, "nov": 11, "dec": 12,
|
||||
}}
|
||||
dowSpec = fieldSpec{0, 6, map[string]int{
|
||||
"sun": 0, "mon": 1, "tue": 2, "wed": 3, "thu": 4, "fri": 5, "sat": 6,
|
||||
}}
|
||||
)
|
||||
|
||||
// Parse розбирає «хвилина година день місяць день_тижня».
|
||||
func Parse(expr string) (*Schedule, error) {
|
||||
fields := strings.Fields(strings.TrimSpace(expr))
|
||||
if len(fields) != 5 {
|
||||
return nil, fmt.Errorf("очікується п'ять полів, отримано %d", len(fields))
|
||||
}
|
||||
|
||||
s := &Schedule{}
|
||||
var err error
|
||||
if s.minute, err = parseField(fields[0], minuteSpec); err != nil {
|
||||
return nil, fmt.Errorf("хвилини: %w", err)
|
||||
}
|
||||
if s.hour, err = parseField(fields[1], hourSpec); err != nil {
|
||||
return nil, fmt.Errorf("години: %w", err)
|
||||
}
|
||||
if s.dom, err = parseField(fields[2], domSpec); err != nil {
|
||||
return nil, fmt.Errorf("день місяця: %w", err)
|
||||
}
|
||||
if s.month, err = parseField(fields[3], monthSpec); err != nil {
|
||||
return nil, fmt.Errorf("місяць: %w", err)
|
||||
}
|
||||
if s.dow, err = parseField(fields[4], dowSpec); err != nil {
|
||||
return nil, fmt.Errorf("день тижня: %w", err)
|
||||
}
|
||||
|
||||
s.domRestricted = fields[2] != "*"
|
||||
s.dowRestricted = fields[4] != "*"
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func parseField(f string, spec fieldSpec) (uint64, error) {
|
||||
var mask uint64
|
||||
for _, part := range strings.Split(f, ",") {
|
||||
m, err := parsePart(strings.TrimSpace(part), spec)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
mask |= m
|
||||
}
|
||||
if mask == 0 {
|
||||
return 0, fmt.Errorf("порожній набір значень")
|
||||
}
|
||||
return mask, nil
|
||||
}
|
||||
|
||||
func parsePart(p string, spec fieldSpec) (uint64, error) {
|
||||
if p == "" {
|
||||
return 0, fmt.Errorf("порожня частина")
|
||||
}
|
||||
|
||||
step := 1
|
||||
if base, stepStr, ok := strings.Cut(p, "/"); ok {
|
||||
n, err := strconv.Atoi(stepStr)
|
||||
if err != nil || n <= 0 {
|
||||
return 0, fmt.Errorf("некоректний крок %q", stepStr)
|
||||
}
|
||||
step = n
|
||||
p = base
|
||||
}
|
||||
|
||||
lo, hi := spec.min, spec.max
|
||||
switch {
|
||||
case p == "*":
|
||||
// уже lo..hi
|
||||
case strings.Contains(p, "-"):
|
||||
a, b, _ := strings.Cut(p, "-")
|
||||
var err error
|
||||
if lo, err = parseValue(a, spec); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if hi, err = parseValue(b, spec); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if lo > hi {
|
||||
return 0, fmt.Errorf("діапазон %q іде навспак", p)
|
||||
}
|
||||
default:
|
||||
v, err := parseValue(p, spec)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
lo, hi = v, v
|
||||
// «5/10» без діапазону означає «від 5 і далі кроком», як у
|
||||
// класичному cron.
|
||||
if step > 1 {
|
||||
hi = spec.max
|
||||
}
|
||||
}
|
||||
|
||||
var mask uint64
|
||||
for v := lo; v <= hi; v += step {
|
||||
mask |= 1 << uint(v)
|
||||
}
|
||||
return mask, nil
|
||||
}
|
||||
|
||||
func parseValue(v string, spec fieldSpec) (int, error) {
|
||||
v = strings.TrimSpace(v)
|
||||
if spec.names != nil {
|
||||
if n, ok := spec.names[strings.ToLower(v)]; ok {
|
||||
return n, nil
|
||||
}
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("не число й не назва: %q", v)
|
||||
}
|
||||
// Неділя як 7 — поширений запис, і відхиляти його було б
|
||||
// причепливістю.
|
||||
if spec.max == 6 && n == 7 {
|
||||
n = 0
|
||||
}
|
||||
if n < spec.min || n > spec.max {
|
||||
return 0, fmt.Errorf("%d поза межами %d..%d", n, spec.min, spec.max)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// Next повертає найближчий момент після from, який підходить розкладу.
|
||||
//
|
||||
// Перебором по хвилинах із обмеженням у чотири роки: високосний рік і
|
||||
// «29 лютого» роблять аналітичне обчислення помітно складнішим за
|
||||
// вигоду, а перебір знаходить відповідь за мілісекунди навіть у
|
||||
// найгіршому випадку.
|
||||
func (s *Schedule) Next(from time.Time) (time.Time, error) {
|
||||
t := from.Truncate(time.Minute).Add(time.Minute)
|
||||
limit := from.AddDate(4, 0, 0)
|
||||
|
||||
for t.Before(limit) {
|
||||
if s.month&(1<<uint(t.Month())) == 0 {
|
||||
// Місяць не підходить — стрибаємо на початок наступного.
|
||||
t = time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, t.Location()).AddDate(0, 1, 0)
|
||||
continue
|
||||
}
|
||||
if !s.dayMatches(t) {
|
||||
t = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()).AddDate(0, 0, 1)
|
||||
continue
|
||||
}
|
||||
if s.hour&(1<<uint(t.Hour())) == 0 {
|
||||
t = time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), 0, 0, 0, t.Location()).
|
||||
Add(time.Hour)
|
||||
continue
|
||||
}
|
||||
if s.minute&(1<<uint(t.Minute())) == 0 {
|
||||
t = t.Add(time.Minute)
|
||||
continue
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
return time.Time{}, fmt.Errorf("розклад не має жодного запуску в найближчі роки")
|
||||
}
|
||||
|
||||
// dayMatches реалізує правило cron про день місяця й день тижня.
|
||||
//
|
||||
// Якщо задане лише одне з полів — діє воно. Якщо обидва — підходить
|
||||
// збіг за будь-яким. Це виглядає нелогічно, доки не спробуєш записати
|
||||
// «щоп'ятниці та 1-го числа»: іншого способу немає.
|
||||
func (s *Schedule) dayMatches(t time.Time) bool {
|
||||
domOK := s.dom&(1<<uint(t.Day())) != 0
|
||||
dowOK := s.dow&(1<<uint(t.Weekday())) != 0
|
||||
|
||||
switch {
|
||||
case s.domRestricted && s.dowRestricted:
|
||||
return domOK || dowOK
|
||||
case s.domRestricted:
|
||||
return domOK
|
||||
case s.dowRestricted:
|
||||
return dowOK
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
146
server/internal/cronx/cron_test.go
Normal file
146
server/internal/cronx/cron_test.go
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
package cronx
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func at(t *testing.T, s string) time.Time {
|
||||
t.Helper()
|
||||
v, err := time.Parse("2006-01-02 15:04", s)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func next(t *testing.T, expr, from string) string {
|
||||
t.Helper()
|
||||
s, err := Parse(expr)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse(%q): %v", expr, err)
|
||||
}
|
||||
n, err := s.Next(at(t, from))
|
||||
if err != nil {
|
||||
t.Fatalf("Next: %v", err)
|
||||
}
|
||||
return n.Format("2006-01-02 15:04")
|
||||
}
|
||||
|
||||
func TestDailyAtThree(t *testing.T) {
|
||||
if got := next(t, "0 3 * * *", "2026-08-16 10:00"); got != "2026-08-17 03:00" {
|
||||
t.Fatalf("отримали %s", got)
|
||||
}
|
||||
// Якщо ще не настало сьогодні — сьогодні ж.
|
||||
if got := next(t, "0 3 * * *", "2026-08-16 01:30"); got != "2026-08-16 03:00" {
|
||||
t.Fatalf("отримали %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEveryFifteenMinutes(t *testing.T) {
|
||||
if got := next(t, "*/15 * * * *", "2026-08-16 10:07"); got != "2026-08-16 10:15" {
|
||||
t.Fatalf("отримали %s", got)
|
||||
}
|
||||
// Рівно на межі — наступний, а не той самий.
|
||||
if got := next(t, "*/15 * * * *", "2026-08-16 10:15"); got != "2026-08-16 10:30" {
|
||||
t.Fatalf("отримали %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAndRange(t *testing.T) {
|
||||
if got := next(t, "0 9,18 * * *", "2026-08-16 10:00"); got != "2026-08-16 18:00" {
|
||||
t.Fatalf("список годин: %s", got)
|
||||
}
|
||||
if got := next(t, "30 8-10 * * *", "2026-08-16 09:45"); got != "2026-08-16 10:30" {
|
||||
t.Fatalf("діапазон: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 2026-08-16 — неділя; найближчий понеділок 17-го.
|
||||
func TestDayOfWeek(t *testing.T) {
|
||||
if got := next(t, "0 2 * * 1", "2026-08-16 12:00"); got != "2026-08-17 02:00" {
|
||||
t.Fatalf("отримали %s", got)
|
||||
}
|
||||
if got := next(t, "0 2 * * mon", "2026-08-16 12:00"); got != "2026-08-17 02:00" {
|
||||
t.Fatalf("назва дня: %s", got)
|
||||
}
|
||||
// Неділя як 7 — поширений запис.
|
||||
if got := next(t, "0 2 * * 7", "2026-08-17 12:00"); got != "2026-08-23 02:00" {
|
||||
t.Fatalf("неділя як 7: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDayOfMonth(t *testing.T) {
|
||||
if got := next(t, "0 4 1 * *", "2026-08-16 12:00"); got != "2026-09-01 04:00" {
|
||||
t.Fatalf("отримали %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Правило cron: якщо задані і день місяця, і день тижня, підходить
|
||||
// збіг за будь-яким. «Щоп'ятниці та 1-го числа» інакше не записати.
|
||||
func TestDomAndDowAreUnion(t *testing.T) {
|
||||
s, err := Parse("0 5 1 * fri")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 2026-08-21 — п'ятниця.
|
||||
n, _ := s.Next(at(t, "2026-08-20 12:00"))
|
||||
if got := n.Format("2006-01-02"); got != "2026-08-21" {
|
||||
t.Fatalf("п'ятниця: %s", got)
|
||||
}
|
||||
// 2026-09-01 — вівторок, але перше число.
|
||||
n, _ = s.Next(at(t, "2026-08-29 12:00"))
|
||||
if got := n.Format("2006-01-02"); got != "2026-09-01" {
|
||||
t.Fatalf("перше число: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonthNames(t *testing.T) {
|
||||
if got := next(t, "0 0 1 jan *", "2026-08-16 12:00"); got != "2027-01-01 00:00" {
|
||||
t.Fatalf("отримали %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 29 лютого існує не щороку — перебір має це переживати, а не зациклитись.
|
||||
func TestLeapDay(t *testing.T) {
|
||||
if got := next(t, "0 0 29 2 *", "2026-03-01 00:00"); got != "2028-02-29 00:00" {
|
||||
t.Fatalf("отримали %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStepInRange(t *testing.T) {
|
||||
if got := next(t, "0 8-20/4 * * *", "2026-08-16 09:00"); got != "2026-08-16 12:00" {
|
||||
t.Fatalf("отримали %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBadExpressions(t *testing.T) {
|
||||
bad := []string{
|
||||
"", "0 3 * *", "0 3 * * * *",
|
||||
"60 3 * * *", "0 24 * * *", "0 3 0 * *", "0 3 * 13 *",
|
||||
"0 3 * * 8", "x 3 * * *", "*/0 * * * *", "10-5 * * * *",
|
||||
}
|
||||
for _, e := range bad {
|
||||
if _, err := Parse(e); err == nil {
|
||||
t.Errorf("%q мав дати помилку", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Розклад, який ніколи не настане, має чесно відмовити, а не зависнути.
|
||||
func TestImpossibleScheduleFails(t *testing.T) {
|
||||
s, err := Parse("0 0 30 2 *") // 30 лютого не буває
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.Next(at(t, "2026-01-01 00:00")); err == nil {
|
||||
t.Fatal("очікували відмову")
|
||||
}
|
||||
}
|
||||
|
||||
// Типовий розклад із seed має розбиратись.
|
||||
func TestDefaultPolicyExpression(t *testing.T) {
|
||||
if _, err := Parse("0 3 * * *"); err != nil {
|
||||
t.Fatalf("типовий розклад не розбирається: %v", err)
|
||||
}
|
||||
}
|
||||
108
server/internal/grpcapi/ncm_schedule.go
Normal file
108
server/internal/grpcapi/ncm_schedule.go
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
package grpcapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/netpulse/netpulse/server/internal/cronx"
|
||||
)
|
||||
|
||||
// scheduleLockKey — окреме advisory-блокування під планувальник бекапів.
|
||||
const scheduleLockKey = 0x6e70_6263 // "npbc"
|
||||
|
||||
// ScheduleBackups ставить бекапи в чергу за розкладом.
|
||||
//
|
||||
// Раз на хвилину, бо cron дрібніший за хвилину не буває. Кілька
|
||||
// екземплярів сервера безпечні: тік бере advisory-блокування, тож
|
||||
// розклад розкручує рівно один — інакше кожен хост отримував би стільки
|
||||
// завдань, скільки інстансів у кластері.
|
||||
func (s *Service) ScheduleBackups(ctx context.Context) {
|
||||
t := time.NewTicker(time.Minute)
|
||||
defer t.Stop()
|
||||
|
||||
s.log.Info("планувальник бекапів запущено")
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
if n := s.scheduleTick(ctx); n > 0 {
|
||||
s.log.Info("бекапи поставлено в чергу за розкладом", "хостів", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) scheduleTick(ctx context.Context) int {
|
||||
conn, err := s.store.Pool().Acquire(ctx)
|
||||
if err != nil {
|
||||
s.log.Error("планувальник: з'єднання", "err", err)
|
||||
return 0
|
||||
}
|
||||
defer conn.Release()
|
||||
|
||||
var got bool
|
||||
if err := conn.QueryRow(ctx, `SELECT pg_try_advisory_lock($1)`,
|
||||
int64(scheduleLockKey)).Scan(&got); err != nil {
|
||||
s.log.Error("планувальник: блокування", "err", err)
|
||||
return 0
|
||||
}
|
||||
if !got {
|
||||
// Розклад розкручує інший інстанс — штатний стан.
|
||||
return 0
|
||||
}
|
||||
defer func() {
|
||||
_, _ = conn.Exec(context.WithoutCancel(ctx),
|
||||
`SELECT pg_advisory_unlock($1)`, int64(scheduleLockKey))
|
||||
}()
|
||||
|
||||
due, err := s.store.DuePolicies(ctx, 200)
|
||||
if err != nil {
|
||||
s.log.Error("планувальник: вибірка розкладів", "err", err)
|
||||
return 0
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
queued := 0
|
||||
|
||||
for _, d := range due {
|
||||
sched, err := cronx.Parse(d.Cron)
|
||||
if err != nil {
|
||||
// Некоректний розклад не має зупиняти решту й не має
|
||||
// повторюватись щохвилини у журналі: відсуваємо політику на
|
||||
// добу, щоб скарга лишилась помітною, але не набридала.
|
||||
s.log.Warn("некоректний розклад бекапу",
|
||||
"device", d.DeviceID, "cron", d.Cron, "err", err)
|
||||
_ = s.store.SetNextBackup(ctx, d.DeviceID, now.Add(24*time.Hour))
|
||||
continue
|
||||
}
|
||||
|
||||
next, err := sched.Next(now)
|
||||
if err != nil {
|
||||
s.log.Warn("розклад без найближчого запуску",
|
||||
"device", d.DeviceID, "cron", d.Cron)
|
||||
_ = s.store.SetNextBackup(ctx, d.DeviceID, now.Add(24*time.Hour))
|
||||
continue
|
||||
}
|
||||
|
||||
// Спершу переносимо позначку, потім ставимо завдання.
|
||||
//
|
||||
// Саме в такому порядку: якщо процес упаде між двома кроками,
|
||||
// хост пропустить один бекап. Зворотний порядок дав би
|
||||
// нескінченну чергу однакових завдань — а це гірше, бо кладе
|
||||
// й пристрій, і сервер.
|
||||
if err := s.store.SetNextBackup(ctx, d.DeviceID, next); err != nil {
|
||||
s.log.Error("планувальник: перенесення розкладу", "err", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if _, err := s.store.EnqueueConfigJob(ctx, d.TenantID, d.DeviceID, "schedule", ""); err != nil {
|
||||
s.log.Error("планувальник: постановка завдання",
|
||||
"device", d.DeviceID, "err", err)
|
||||
continue
|
||||
}
|
||||
queued++
|
||||
}
|
||||
return queued
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/netpulse/netpulse/server/internal/cronx"
|
||||
"github.com/netpulse/netpulse/server/internal/store"
|
||||
)
|
||||
|
||||
|
|
@ -340,3 +341,92 @@ func (s *Server) handleDiffConfigs(w http.ResponseWriter, r *http.Request, p *Pr
|
|||
}
|
||||
writeJSON(w, http.StatusOK, res)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Розклад бекапу
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
func (s *Server) handleGetBackupPolicy(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||
if !requirePerm(w, p, "ncm:read") {
|
||||
return
|
||||
}
|
||||
deviceID := r.PathValue("id")
|
||||
if !p.Scope().CanRead(deviceID) {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "немає доступу до цього хоста")
|
||||
return
|
||||
}
|
||||
|
||||
pol, err := s.store.GetBackupPolicy(r.Context(), p.TenantID, deviceID)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, "розклад бекапу", err)
|
||||
return
|
||||
}
|
||||
// Політики немає — це не помилка, а «ще не налаштовано».
|
||||
writeJSON(w, http.StatusOK, map[string]any{"policy": pol})
|
||||
}
|
||||
|
||||
func (s *Server) handleSetBackupPolicy(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||
if !requirePerm(w, p, "ncm:write") {
|
||||
return
|
||||
}
|
||||
deviceID := r.PathValue("id")
|
||||
if !p.Scope().CanWrite(deviceID) {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "немає доступу на запис до цього хоста")
|
||||
return
|
||||
}
|
||||
|
||||
var in struct {
|
||||
ProfileID string `json:"profile_id"`
|
||||
CredentialID string `json:"credential_id"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
Cron string `json:"cron"`
|
||||
OnSyslog *bool `json:"on_syslog"`
|
||||
}
|
||||
if !decodeBody(w, r, &in) {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(in.Cron) == "" {
|
||||
in.Cron = "0 3 * * *"
|
||||
}
|
||||
// Розклад перевіряємо тут, а не в планувальнику: інакше людина
|
||||
// дізнається про друкарську помилку через добу мовчання, а не
|
||||
// одразу при збереженні.
|
||||
if _, err := cronx.Parse(in.Cron); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_cron", "розклад: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
err := s.store.SetBackupPolicy(r.Context(), p.TenantID, store.BackupPolicy{
|
||||
DeviceID: deviceID,
|
||||
ProfileID: in.ProfileID,
|
||||
CredentialID: in.CredentialID,
|
||||
Enabled: in.Enabled == nil || *in.Enabled,
|
||||
Cron: in.Cron,
|
||||
OnSyslog: in.OnSyslog != nil && *in.OnSyslog,
|
||||
})
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "хост не знайдено")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
s.writeStoreError(w, "збереження розкладу", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleListProfiles — довідник профілів для форми розкладу.
|
||||
func (s *Server) handleListProfiles(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||
if !requirePerm(w, p, "ncm:read") {
|
||||
return
|
||||
}
|
||||
list, err := s.store.ListProfiles(r.Context(), p.TenantID)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, "перелік профілів", err)
|
||||
return
|
||||
}
|
||||
if list == nil {
|
||||
list = []map[string]any{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"profiles": list})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -96,6 +96,9 @@ func (s *Server) Handler() http.Handler {
|
|||
mux.Handle("GET /api/v1/devices/{id}/configs", s.authenticated(s.handleListConfigs))
|
||||
mux.Handle("GET /api/v1/configs/{id}", s.authenticated(s.handleGetConfig))
|
||||
mux.Handle("GET /api/v1/configs/{id}/diff", s.authenticated(s.handleDiffConfigs))
|
||||
mux.Handle("GET /api/v1/devices/{id}/backup-policy", s.authenticated(s.handleGetBackupPolicy))
|
||||
mux.Handle("PUT /api/v1/devices/{id}/backup-policy", s.authenticated(s.handleSetBackupPolicy))
|
||||
mux.Handle("GET /api/v1/ncm-profiles", s.authenticated(s.handleListProfiles))
|
||||
|
||||
mux.Handle("GET /api/v1/check-types", s.authenticated(s.handleListCheckTypes))
|
||||
mux.Handle("GET /api/v1/devices/{id}/checks", s.authenticated(s.handleListDeviceChecks))
|
||||
|
|
|
|||
169
server/internal/store/ncm_schedule.go
Normal file
169
server/internal/store/ncm_schedule.go
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// BackupPolicy — розклад бекапу одного хоста.
|
||||
type BackupPolicy struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
ProfileID string `json:"profile_id,omitempty"`
|
||||
CredentialID string `json:"credential_id,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Cron string `json:"cron"`
|
||||
OnSyslog bool `json:"on_syslog"`
|
||||
LastBackupAt *time.Time `json:"last_backup_at,omitempty"`
|
||||
NextBackupAt *time.Time `json:"next_backup_at,omitempty"`
|
||||
}
|
||||
|
||||
// DuePolicy — політика, якій настав час.
|
||||
type DuePolicy struct {
|
||||
TenantID string
|
||||
DeviceID string
|
||||
Cron string
|
||||
}
|
||||
|
||||
// DuePolicies повертає розклади, час яких настав.
|
||||
//
|
||||
// Порожній next_backup_at теж вважається настанням: так виглядає щойно
|
||||
// створена політика, і чекати на неї добу означало б не зробити перший
|
||||
// бекап тоді, коли він найпотрібніший.
|
||||
func (s *Store) DuePolicies(ctx context.Context, limit int) ([]DuePolicy, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT p.tenant_id::text, p.device_id::text, p.cron
|
||||
FROM ncm.device_policies p
|
||||
JOIN inv.devices d ON d.id = p.device_id
|
||||
JOIN core.tenants t ON t.id = p.tenant_id
|
||||
WHERE p.enabled
|
||||
AND d.enabled AND d.deleted_at IS NULL
|
||||
AND t.status NOT IN ('suspended','cancelled')
|
||||
AND (p.next_backup_at IS NULL OR p.next_backup_at <= now())
|
||||
ORDER BY p.next_backup_at NULLS FIRST
|
||||
LIMIT $1
|
||||
`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []DuePolicy
|
||||
for rows.Next() {
|
||||
var d DuePolicy
|
||||
if err := rows.Scan(&d.TenantID, &d.DeviceID, &d.Cron); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, d)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// SetNextBackup переносить політику на наступний запуск.
|
||||
//
|
||||
// Викликається одразу після постановки завдання в чергу, а не після
|
||||
// його виконання: інакше хост, до якого зонд не достукався, лишався б
|
||||
// «простроченим» і породжував нове завдання щотіку.
|
||||
func (s *Store) SetNextBackup(ctx context.Context, deviceID string, next time.Time) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
UPDATE ncm.device_policies SET next_backup_at = $2 WHERE device_id = $1
|
||||
`, deviceID, next)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetBackupPolicy читає політику хоста.
|
||||
func (s *Store) GetBackupPolicy(ctx context.Context, tenantID, deviceID string) (*BackupPolicy, error) {
|
||||
var p BackupPolicy
|
||||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||
return tx.QueryRow(ctx, `
|
||||
SELECT device_id::text, COALESCE(profile_id::text,''),
|
||||
COALESCE(credential_id::text,''), enabled, cron, on_syslog,
|
||||
last_backup_at, next_backup_at
|
||||
FROM ncm.device_policies
|
||||
WHERE device_id = $1 AND tenant_id = $2
|
||||
`, deviceID, tenantID).Scan(&p.DeviceID, &p.ProfileID, &p.CredentialID,
|
||||
&p.Enabled, &p.Cron, &p.OnSyslog, &p.LastBackupAt, &p.NextBackupAt)
|
||||
})
|
||||
if err != nil {
|
||||
if isNoRows(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// SetBackupPolicy створює або оновлює політику.
|
||||
//
|
||||
// next_backup_at обнуляється при кожній зміні: розклад міг стати
|
||||
// частішим, і лишити стару позначку означало б чекати за старим
|
||||
// розкладом до першого спрацювання нового.
|
||||
func (s *Store) SetBackupPolicy(ctx context.Context, tenantID string, p BackupPolicy) error {
|
||||
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||
var owned bool
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT EXISTS (SELECT 1 FROM inv.devices
|
||||
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL)
|
||||
`, p.DeviceID, tenantID).Scan(&owned); err != nil {
|
||||
return err
|
||||
}
|
||||
if !owned {
|
||||
return ErrNotFound
|
||||
}
|
||||
|
||||
_, err := tx.Exec(ctx, `
|
||||
INSERT INTO ncm.device_policies
|
||||
(device_id, tenant_id, profile_id, credential_id, enabled, cron,
|
||||
on_syslog, next_backup_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, NULL)
|
||||
ON CONFLICT (device_id) DO UPDATE SET
|
||||
profile_id = EXCLUDED.profile_id,
|
||||
credential_id = EXCLUDED.credential_id,
|
||||
enabled = EXCLUDED.enabled,
|
||||
cron = EXCLUDED.cron,
|
||||
on_syslog = EXCLUDED.on_syslog,
|
||||
next_backup_at = NULL,
|
||||
updated_at = now()
|
||||
`, p.DeviceID, tenantID, nullUUID(p.ProfileID), nullUUID(p.CredentialID),
|
||||
p.Enabled, p.Cron, p.OnSyslog)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// ListProfiles — довідник профілів для форми.
|
||||
func (s *Store) ListProfiles(ctx context.Context, tenantID string) ([]map[string]any, error) {
|
||||
var out []map[string]any
|
||||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT id::text, key, name, COALESCE(vendor,''), is_builtin
|
||||
FROM ncm.profiles
|
||||
WHERE tenant_id IS NULL OR tenant_id = $1
|
||||
ORDER BY vendor, name
|
||||
`, tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var id, key, name, vendor string
|
||||
var builtin bool
|
||||
if err := rows.Scan(&id, &key, &name, &vendor, &builtin); err != nil {
|
||||
return err
|
||||
}
|
||||
out = append(out, map[string]any{
|
||||
"id": id, "key": key, "name": name,
|
||||
"vendor": vendor, "is_builtin": builtin,
|
||||
})
|
||||
}
|
||||
return rows.Err()
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
func isNoRows(err error) bool {
|
||||
return err != nil && err.Error() == pgx.ErrNoRows.Error()
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import type {
|
|||
BuildResult,
|
||||
Channel,
|
||||
Check,
|
||||
BackupPolicy,
|
||||
ConfigJobRow,
|
||||
ConfigVersion,
|
||||
CheckType,
|
||||
|
|
@ -15,6 +16,7 @@ import type {
|
|||
DeviceGroup,
|
||||
DeviceSummary,
|
||||
DiffResult,
|
||||
NcmProfile,
|
||||
LoginResponse,
|
||||
MapPatch,
|
||||
MapState,
|
||||
|
|
@ -291,6 +293,27 @@ export const api = {
|
|||
diffConfig: (id: string, from?: string) =>
|
||||
request<DiffResult>(`/api/v1/configs/${id}/diff${from ? `?from=${from}` : ''}`),
|
||||
|
||||
getBackupPolicy: (deviceID: string) =>
|
||||
request<{ policy: BackupPolicy | null }>(`/api/v1/devices/${deviceID}/backup-policy`)
|
||||
.then((r) => r.policy),
|
||||
|
||||
setBackupPolicy: (
|
||||
deviceID: string,
|
||||
p: {
|
||||
profile_id?: string
|
||||
credential_id?: string
|
||||
enabled: boolean
|
||||
cron: string
|
||||
on_syslog?: boolean
|
||||
},
|
||||
) => request<void>(`/api/v1/devices/${deviceID}/backup-policy`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(p),
|
||||
}),
|
||||
|
||||
listNcmProfiles: () =>
|
||||
request<{ profiles: NcmProfile[] }>('/api/v1/ncm-profiles').then((r) => r.profiles ?? []),
|
||||
|
||||
listCredentials: () =>
|
||||
request<{ credentials: Credential[] }>('/api/v1/credentials').then((r) => r.credentials ?? []),
|
||||
|
||||
|
|
|
|||
196
web/src/components/BackupPolicyForm.tsx
Normal file
196
web/src/components/BackupPolicyForm.tsx
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../api/client'
|
||||
import { Button, ErrorNote, Field, Modal, Toggle, inputClass } from './ui'
|
||||
import type { BackupPolicy, Credential, DeviceSummary, NcmProfile } from '../types'
|
||||
|
||||
// Готові розклади покривають майже все, що справді налаштовують.
|
||||
// Довільний cron лишається, але не мусить бути першим, що бачить людина.
|
||||
const PRESETS: { cron: string; label: string }[] = [
|
||||
{ cron: '0 3 * * *', label: 'щодня о 03:00' },
|
||||
{ cron: '0 */6 * * *', label: 'кожні 6 годин' },
|
||||
{ cron: '0 3 * * 1', label: 'щопонеділка о 03:00' },
|
||||
{ cron: '0 3 1 * *', label: 'першого числа о 03:00' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Розклад резервного копіювання хоста.
|
||||
*
|
||||
* Профіль можна не задавати: сервер підбере його за виробником. Явно
|
||||
* заданий завжди виграє — це шлях для випадків, коли прошивка
|
||||
* поводиться не так, як решта родини.
|
||||
*/
|
||||
export function BackupPolicyForm({
|
||||
device,
|
||||
credentials,
|
||||
onClose,
|
||||
}: {
|
||||
device: DeviceSummary
|
||||
credentials: Credential[]
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [profiles, setProfiles] = useState<NcmProfile[]>([])
|
||||
const [policy, setPolicy] = useState<BackupPolicy | null>(null)
|
||||
const [enabled, setEnabled] = useState(true)
|
||||
const [cron, setCron] = useState('0 3 * * *')
|
||||
// Окремий прапорець, а не висновок із самого виразу: вибір «свій
|
||||
// розклад» має відкрити поле навіть тоді, коли поточне значення
|
||||
// випадково збігається з пресетом.
|
||||
const [customMode, setCustomMode] = useState(false)
|
||||
const [profileID, setProfileID] = useState('')
|
||||
const [credID, setCredID] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
Promise.all([
|
||||
api.listNcmProfiles().catch(() => [] as NcmProfile[]),
|
||||
api.getBackupPolicy(device.id).catch(() => null),
|
||||
])
|
||||
.then(([pr, pol]) => {
|
||||
if (!alive) return
|
||||
setProfiles(pr)
|
||||
setPolicy(pol)
|
||||
if (pol) {
|
||||
setEnabled(pol.enabled)
|
||||
setCron(pol.cron)
|
||||
setCustomMode(!PRESETS.some((x) => x.cron === pol.cron))
|
||||
setProfileID(pol.profile_id ?? '')
|
||||
setCredID(pol.credential_id ?? '')
|
||||
}
|
||||
})
|
||||
.finally(() => alive && setLoading(false))
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [device.id])
|
||||
|
||||
const cliCreds = credentials.filter((c) => c.proto === 'ssh' || c.proto === 'telnet')
|
||||
const custom = customMode || !PRESETS.some((p) => p.cron === cron)
|
||||
|
||||
return (
|
||||
<Modal title={`Резервне копіювання: ${device.name}`} onClose={onClose}>
|
||||
{loading ? (
|
||||
<p className="py-6 text-center text-xs text-slate-600">Завантаження…</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center gap-2.5 text-sm text-slate-300">
|
||||
<Toggle checked={enabled} onChange={setEnabled} />
|
||||
Збирати конфіг за розкладом
|
||||
</label>
|
||||
|
||||
<Field label="Розклад">
|
||||
<select
|
||||
className={inputClass}
|
||||
value={custom ? '' : cron}
|
||||
onChange={(e) => {
|
||||
if (e.target.value === '') {
|
||||
setCustomMode(true)
|
||||
return
|
||||
}
|
||||
setCustomMode(false)
|
||||
setCron(e.target.value)
|
||||
}}
|
||||
>
|
||||
{PRESETS.map((p) => (
|
||||
<option key={p.cron} value={p.cron}>
|
||||
{p.label}
|
||||
</option>
|
||||
))}
|
||||
<option value="">свій розклад…</option>
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
{custom && (
|
||||
<Field
|
||||
label="Вираз cron"
|
||||
hint="Хвилина, година, день місяця, місяць, день тижня — як у crontab"
|
||||
>
|
||||
<input
|
||||
className={`${inputClass} font-mono`}
|
||||
value={cron}
|
||||
onChange={(e) => setCron(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Field
|
||||
label="Профіль"
|
||||
hint="Не задано — сервер підбере за виробником хоста"
|
||||
>
|
||||
<select
|
||||
className={inputClass}
|
||||
value={profileID}
|
||||
onChange={(e) => setProfileID(e.target.value)}
|
||||
>
|
||||
<option value="">— автоматично —</option>
|
||||
{profiles.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name} ({p.key})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Доступ"
|
||||
hint={
|
||||
cliCreds.length === 0
|
||||
? 'Немає жодного доступу ssh або telnet — без нього збір неможливий'
|
||||
: 'Не задано — візьметься прив’язаний до хоста'
|
||||
}
|
||||
>
|
||||
<select
|
||||
className={inputClass}
|
||||
value={credID}
|
||||
onChange={(e) => setCredID(e.target.value)}
|
||||
>
|
||||
<option value="">— автоматично —</option>
|
||||
{cliCreds.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name} ({c.proto})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
{policy?.next_backup_at && enabled && (
|
||||
<p className="text-[11px] text-slate-600">
|
||||
Наступний запуск: {new Date(policy.next_backup_at).toLocaleString('uk-UA')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ErrorNote>{err}</ErrorNote>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<Button onClick={onClose}>Скасувати</Button>
|
||||
<Button
|
||||
kind="primary"
|
||||
disabled={busy || cron.trim() === ''}
|
||||
onClick={async () => {
|
||||
setBusy(true)
|
||||
setErr(null)
|
||||
try {
|
||||
await api.setBackupPolicy(device.id, {
|
||||
profile_id: profileID,
|
||||
credential_id: credID,
|
||||
enabled,
|
||||
cron: cron.trim(),
|
||||
})
|
||||
onClose()
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{busy ? 'Збереження…' : 'Зберегти'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
|
@ -12,7 +12,15 @@ import {
|
|||
ago,
|
||||
inputClass,
|
||||
} from '../components/ui'
|
||||
import type { ConfigJobRow, ConfigVersion, DeviceSummary, DiffResult } from '../types'
|
||||
import { BackupPolicyForm } from '../components/BackupPolicyForm'
|
||||
import type {
|
||||
BackupPolicy,
|
||||
ConfigJobRow,
|
||||
ConfigVersion,
|
||||
Credential,
|
||||
DeviceSummary,
|
||||
DiffResult,
|
||||
} from '../types'
|
||||
|
||||
const jobTone: Record<string, string> = {
|
||||
success: 'up',
|
||||
|
|
@ -45,14 +53,18 @@ export function ConfigsPage() {
|
|||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [query, setQuery] = useState('')
|
||||
const [creds, setCreds] = useState<Credential[]>([])
|
||||
|
||||
const canCollect = session.can('ncm:write')
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.listDevices()
|
||||
.then((d) => {
|
||||
Promise.all([
|
||||
api.listDevices(),
|
||||
api.listCredentials().catch(() => [] as Credential[]),
|
||||
])
|
||||
.then(([d, c]) => {
|
||||
setDevices(d)
|
||||
setCreds(c)
|
||||
setSelected((cur) => cur ?? d[0] ?? null)
|
||||
})
|
||||
.catch((e) => setError(e instanceof Error ? e.message : String(e)))
|
||||
|
|
@ -115,7 +127,7 @@ export function ConfigsPage() {
|
|||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-3 md:p-4">
|
||||
{selected ? (
|
||||
<DeviceConfigs device={selected} canCollect={canCollect} />
|
||||
<DeviceConfigs device={selected} canCollect={canCollect} credentials={creds} />
|
||||
) : (
|
||||
<p className="py-16 text-center text-sm text-slate-600">Оберіть хост</p>
|
||||
)}
|
||||
|
|
@ -127,22 +139,34 @@ export function ConfigsPage() {
|
|||
)
|
||||
}
|
||||
|
||||
function DeviceConfigs({ device, canCollect }: { device: DeviceSummary; canCollect: boolean }) {
|
||||
function DeviceConfigs({
|
||||
device,
|
||||
canCollect,
|
||||
credentials,
|
||||
}: {
|
||||
device: DeviceSummary
|
||||
canCollect: boolean
|
||||
credentials: Credential[]
|
||||
}) {
|
||||
const [versions, setVersions] = useState<ConfigVersion[]>([])
|
||||
const [jobs, setJobs] = useState<ConfigJobRow[]>([])
|
||||
const [open, setOpen] = useState<ConfigVersion | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [policy, setPolicy] = useState<BackupPolicy | null>(null)
|
||||
const [editPolicy, setEditPolicy] = useState(false)
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
try {
|
||||
const [v, j] = await Promise.all([
|
||||
const [v, j, pol] = await Promise.all([
|
||||
api.listConfigs(device.id),
|
||||
api.listConfigJobs(device.id).catch(() => [] as ConfigJobRow[]),
|
||||
api.getBackupPolicy(device.id).catch(() => null),
|
||||
])
|
||||
setVersions(v)
|
||||
setJobs(j)
|
||||
setPolicy(pol)
|
||||
setOpen((cur) => (cur && v.some((x) => x.id === cur.id) ? cur : v[0] ?? null))
|
||||
setErr(null)
|
||||
} catch (e) {
|
||||
|
|
@ -177,9 +201,18 @@ function DeviceConfigs({ device, canCollect }: { device: DeviceSummary; canColle
|
|||
{versions.length} версій
|
||||
{versions[0] && ` · остання ${ago(versions[0].collected_at)} тому`}
|
||||
</span>
|
||||
<span className="ml-auto flex items-center gap-2">
|
||||
<span className="text-xs text-slate-600">
|
||||
{policy?.enabled
|
||||
? `за розкладом ${policy.cron}`
|
||||
: 'розклад не налаштовано'}
|
||||
</span>
|
||||
{canCollect && (
|
||||
<Button onClick={() => setEditPolicy(true)}>Розклад</Button>
|
||||
)}
|
||||
</span>
|
||||
{canCollect && (
|
||||
<Button
|
||||
className="ml-auto"
|
||||
kind="primary"
|
||||
disabled={busy || running}
|
||||
onClick={async () => {
|
||||
|
|
@ -217,6 +250,17 @@ function DeviceConfigs({ device, canCollect }: { device: DeviceSummary; canColle
|
|||
</Card>
|
||||
)}
|
||||
|
||||
{editPolicy && (
|
||||
<BackupPolicyForm
|
||||
device={device}
|
||||
credentials={credentials}
|
||||
onClose={() => {
|
||||
setEditPolicy(false)
|
||||
void reload()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<Spinner />
|
||||
) : versions.length === 0 ? (
|
||||
|
|
|
|||
|
|
@ -261,6 +261,25 @@ export interface DiffResult {
|
|||
first?: boolean
|
||||
}
|
||||
|
||||
export interface BackupPolicy {
|
||||
device_id: string
|
||||
profile_id?: string
|
||||
credential_id?: string
|
||||
enabled: boolean
|
||||
cron: string
|
||||
on_syslog: boolean
|
||||
last_backup_at?: string
|
||||
next_backup_at?: string
|
||||
}
|
||||
|
||||
export interface NcmProfile {
|
||||
id: string
|
||||
key: string
|
||||
name: string
|
||||
vendor: string
|
||||
is_builtin: boolean
|
||||
}
|
||||
|
||||
export interface AgentSummary {
|
||||
id: string
|
||||
name: string
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue