diff --git a/HISTORY.md b/HISTORY.md index 82c9249..64827a7 100644 --- a/HISTORY.md +++ b/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 * * *`. diff --git a/ROADMAP.md b/ROADMAP.md index 65df1d5..99dacb4 100644 --- a/ROADMAP.md +++ b/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/`, віддає diff --git a/server/API.md b/server/API.md index 26b64ec..2c5d69b 100644 --- a/server/API.md +++ b/server/API.md @@ -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` завдання повертається у відмову за десять хвилин: зонд міг зникнути разом із ним, і без цього хост лишився б без бекапів назавжди. diff --git a/server/internal/cronx/cron.go b/server/internal/cronx/cron.go new file mode 100644 index 0000000..eedba52 --- /dev/null +++ b/server/internal/cronx/cron.go @@ -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< 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 +} diff --git a/server/internal/httpapi/checks.go b/server/internal/httpapi/checks.go index d33e6d8..8cd027d 100644 --- a/server/internal/httpapi/checks.go +++ b/server/internal/httpapi/checks.go @@ -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}) +} diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go index 08db2d8..f693781 100644 --- a/server/internal/httpapi/server.go +++ b/server/internal/httpapi/server.go @@ -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)) diff --git a/server/internal/store/ncm_schedule.go b/server/internal/store/ncm_schedule.go new file mode 100644 index 0000000..e720eb5 --- /dev/null +++ b/server/internal/store/ncm_schedule.go @@ -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() +} diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 2532604..35567a4 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -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(`/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(`/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 ?? []), diff --git a/web/src/components/BackupPolicyForm.tsx b/web/src/components/BackupPolicyForm.tsx new file mode 100644 index 0000000..c20107d --- /dev/null +++ b/web/src/components/BackupPolicyForm.tsx @@ -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([]) + const [policy, setPolicy] = useState(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(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 ( + + {loading ? ( +

Завантаження…

+ ) : ( +
+ + + + + + + {custom && ( + + setCron(e.target.value)} + /> + + )} + + + + + + + + + + {policy?.next_backup_at && enabled && ( +

+ Наступний запуск: {new Date(policy.next_backup_at).toLocaleString('uk-UA')} +

+ )} + + {err} + +
+ + +
+
+ )} +
+ ) +} diff --git a/web/src/pages/ConfigsPage.tsx b/web/src/pages/ConfigsPage.tsx index c6db774..5cf0d7e 100644 --- a/web/src/pages/ConfigsPage.tsx +++ b/web/src/pages/ConfigsPage.tsx @@ -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 = { success: 'up', @@ -45,14 +53,18 @@ export function ConfigsPage() { const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [query, setQuery] = useState('') + const [creds, setCreds] = useState([]) 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() {
{selected ? ( - + ) : (

Оберіть хост

)} @@ -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([]) const [jobs, setJobs] = useState([]) const [open, setOpen] = useState(null) const [busy, setBusy] = useState(false) const [err, setErr] = useState(null) const [loading, setLoading] = useState(true) + const [policy, setPolicy] = useState(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)} тому`} + + + {policy?.enabled + ? `за розкладом ${policy.cron}` + : 'розклад не налаштовано'} + + {canCollect && ( + + )} + {canCollect && (