Netpulse_SasS/server/internal/httpapi/devices_bulk.go
byrsapty ed8fc831bf Дві сесії роботи: 0058–0068, розгортання однією командою, тести
Один коміт, а не десяток тематичних, свідомо: теми переплетені в
спільних файлах (store.go, docker-compose.yml, deploy/README.md), і
розділити їх можна було б лише індексуванням шматків. Коміти, які не
збираються, гірші за один великий — тим паче що це рівно той стан, який
перевірявся разом.

ЩО ПРАЦЮЄ НА СТЕНДІ Й ПЕРЕВІРЕНО ТАМ

  0058  подієві алерти: syslog, ncm, compliance спрацьовують у мить
        події; правило з нереалізованим джерелом більше не зберігається
        мовчки
  0059  snmp.walk і прототипи шаблонів — таблиці з динамічним індексом
        описуються шаблоном, а не Go
  0060  відкат конфігу: план як різниця, маскування паролів із підписом
        плану, обов'язковий контрольний збір, verifying при обриві
  0061  кнопки Telegram: довге опитування, авторизація не з callback_data
  0062  аудит і архів хостів; тест на AST, що падає на ключі без назви
  0063  RLS: три ролі, окремий пул для фонових тактів
  0064  строки зберігання даних і сторінка сховища
  0065  приймач SNMP-трапів; перевірено справжніми пакетами по дроту,
        переклад v1→v2 за RFC 3584 дає правильний OID
  0066  ескалації сповіщень
  0067  алерт про вичерпання диска
  0068  поля заливки конфігу переїхали в каталог профілів

Плюс: 137 тестів вебу з нуля (їх не було взагалі), одинадцять справжніх
вад, знайдених ними й виправлених, і виправлення двох інтеграційних
тестів grpcapi, які мовчки пропускались півтора року.

ЩО ЩЕ НЕ ЗАПУСКАЛОСЬ

  netpulse            установник: одна команда замість 18 змінних і
                      593 рядків інструкції
  RLS з першого запуску  нова інсталяція під політиками одразу;
                      RLS-EXISTING-INSTALL.md лишається тільки для
                      старих інсталяцій
  .forgejo + CI       раннер не зареєстрований

Ці три перевірені компіляцією й міркуванням, але не виконанням.

ГОЛОВНИЙ ВИСНОВОК ДВОХ СЕСІЙ

Зелена перевірка доводить рівно те, що вона перевіряє. Тест ізоляції RLS
був правильний і зелений — і пропустив зламаний вхід, бо перевіряв «чи
не видно чужого», коли зламалось «чи видно своє». Інтеграційні тести
grpcapi були зелені, бо не виконувались. Схема, довідник і протокол
описували те, чого в коді не існувало, і виглядало це як готове.

Тому в кожному завданні цих сесій стояла вимога назвати НЕПОКРИТЕ, а
чотири задачі закінчились не можливістю, а відмовою: правило з
нереалізованим джерелом не зберігається, профіль без команд заливки
каже про це замість мовчазної кнопки, міграція RLS валить сама себе на
таблиці без політики, тест словника аудиту падає на ключі без назви.

Подробиці — HISTORY.md, розділи за 26 і 27 серпня.
2026-08-27 17:32:49 +03:00

628 lines
27 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package httpapi
import (
"encoding/json"
"net/http"
"strings"
"github.com/netpulse/netpulse/server/internal/cronx"
"github.com/netpulse/netpulse/server/internal/store"
)
// Масова правка й масове видалення хостів.
//
// Запобіжники тут ті самі, що й у масовому виконанні команд, і саме
// тому, що вони ті самі: дві масові операції в одній системі мусять
// поводитись однаково, інакше людина, яка звикла до однієї, помилиться
// в другій.
//
// - право devices:write, а не лише devices:read;
// - у набір потрапляє тільки те, що людині дозволено ЗМІНЮВАТИ
// (Scope.Writable), а не те, що вона бачить;
// - клієнт шле перелік id разом із фільтром, сервер бере ПЕРЕТИН, а
// те, що відпало, повертає окремим списком — мовчазна різниця між
// підтвердженим і зробленим тут неприпустима;
// - стеля на кількість за раз (store.MaxBulkDevices);
// - кожна масова дія — рядок в аудиті з іменами хостів.
//
// Окремим правом, як ncm:exec, ця дія не стала свідомо. ncm:exec
// закриває те, чого інакше не зробити взагалі, — виконання довільної
// команди на залізі. Тут же немає жодної зміни, якої не можна було б
// зробити картками хостів по одній: масова форма економить час, а не
// відмикає нові можливості. Заводити під економію часу окреме право
// означало б, що інженер із devices:write, який має видаляти хости,
// видаляє їх поштучно й довше — з тим самим результатом.
// bulkDeviceBody — спільне тіло всіх трьох ручок.
//
// Фільтр і перелік id разом: перелік каже, що людина бачила на екрані,
// фільтр — за яким наміром вона це відбирала. Сервер бере перетин, тож
// хост, заведений між переглядом і натисканням, у дію не потрапить,
// навіть якщо підпадає під фільтр.
type bulkDeviceBody struct {
Filter store.DeviceFilter `json:"filter"`
DeviceIDs []string `json:"device_ids"`
Changes store.BulkDeviceChanges `json:"changes"`
// Mode — яке саме видалення. Читається ручкою видалення, а ще —
// добором цілей, бо від нього залежить, чи видно прибрані хости.
//
// archive — хост зникає з переліків, зібране лишається в базі
// purge — хоста й усього, що з ним пов'язане, більше немає
// restore — не видалення: добір цілей для повернення з архіву
//
// Порожнє значення означає archive, і це не заготовка «на потім», а
// сумісність: ручка існувала до появи вибору, і клієнт, який про
// нього не знає, не має отримати незворотну дію замість тієї, яку
// просив.
Mode string `json:"mode"`
}
// resolveBulk — спільний перший крок: перетин і те, що з нього випало.
func (s *Server) resolveBulk(w http.ResponseWriter, r *http.Request, p *Principal,
in bulkDeviceBody) ([]store.BulkDeviceTarget, []string, bool) {
// Повне видалення й повернення бачать і прибраних. Інакше «прибрати
// з переліку» було б пасткою: хост зникає звідусіль разом із
// можливістю його добити, а зібране лишається в базі назавжди.
//
// Дві дії, які адресовані САМЕ прибраним хостам, і жодної третьої:
// масова правка архіву сенсу не має (міняти налаштування тому, кого
// в переліках немає, — це спосіб повернути його наполовину), а
// архівне видалення вже архівованого не робить нічого.
resolve := s.store.ResolveBulkTargets
if in.Mode == "purge" || in.Mode == "restore" {
resolve = s.store.ResolveBulkPurgeTargets
}
targets, err := resolve(r.Context(), p.TenantID, p.Scope(),
in.Filter, in.DeviceIDs)
if err != nil {
s.writeStoreError(w, "добір хостів для масової дії", err)
return nil, nil, false
}
keep := make(map[string]bool, len(targets))
for _, t := range targets {
keep[t.DeviceID] = true
}
dropped := []string{}
for _, id := range in.DeviceIDs {
if !keep[id] {
dropped = append(dropped, id)
}
}
return targets, dropped, true
}
// handleBulkDeviceTargets — «що саме я зараз зачеплю».
//
// Окремий крок і окрема ручка, як і в масовому виконанні команд:
// підтвердження без точного переліку не вважається підтвердженням, і
// клієнт не має змоги його оминути. Разом із переліком їдуть числа
// прив'язаного — чеки, алерти, вузли на мапах, розклад: усе, про що
// питають ПІСЛЯ видалення, має бути видно ДО нього.
func (s *Server) handleBulkDeviceTargets(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "devices:write") {
return
}
var in bulkDeviceBody
if !decodeBody(w, r, &in) {
return
}
targets, dropped, ok := s.resolveBulk(w, r, p, in)
if !ok {
return
}
if targets == nil {
targets = []store.BulkDeviceTarget{}
}
writeJSON(w, http.StatusOK, map[string]any{
"devices": targets,
// Хости, які людина відмітила, але які не пройшли: або зникли,
// або лишились без доступу на запис, або більше не підпадають
// під фільтр. Мовчати про них не можна — інакше «обрано 50,
// змінено 43» людина побачить лише в аудиті.
"dropped": dropped,
"max": store.MaxBulkDevices,
})
}
func (s *Server) handleBulkUpdateDevices(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "devices:write") {
return
}
var in bulkDeviceBody
if !decodeBody(w, r, &in) {
return
}
// Розклад збору конфігів живе за іншим правом і в іншому розділі.
// Масова форма не має ставати обхідним шляхом до нього: хто не
// може змінити розклад одному хосту, не міняє його й п'ятдесяти.
if in.Changes.Backup != nil {
if !requirePerm(w, p, "ncm:write") {
return
}
if in.Changes.Backup.Op == "set" {
cron := strings.TrimSpace(in.Changes.Backup.Cron)
if cron == "" {
cron = "0 3 * * *"
}
// Розклад перевіряємо тут, а не в планувальнику: інакше
// людина дізнається про друкарську помилку через добу
// мовчання — і одразу на всіх хостах набору.
if _, err := cronx.Parse(cron); err != nil {
writeError(w, http.StatusBadRequest, "bad_cron", "розклад: "+err.Error())
return
}
in.Changes.Backup.Cron = cron
}
}
if in.Changes.Kind != nil && in.Changes.Kind.Op == "set" &&
!validDeviceKinds[in.Changes.Kind.Value] {
writeError(w, http.StatusBadRequest, "bad_kind", "невідомий тип пристрою")
return
}
if !s.checkBulkChecksOp(w, r, p, in.Changes.Checks) {
return
}
targets, dropped, ok := s.resolveBulk(w, r, p, in)
if !ok {
return
}
if len(targets) == 0 {
writeError(w, http.StatusBadRequest, "no_targets",
"жоден з обраних хостів більше не доступний на запис — перегляньте перелік ще раз")
return
}
ids := make([]string, 0, len(targets))
for _, t := range targets {
ids = append(ids, t.DeviceID)
}
changed, names, checkRes, err := s.store.BulkUpdateDevices(r.Context(), p.TenantID, p.Scope(),
ids, in.Changes)
if err != nil {
s.writeStoreError(w, "масова правка хостів", err)
return
}
meta := map[string]any{
"devices": len(changed),
"device_ids": changed,
// Імена, а не лише id: через рік хостів може вже не бути, а
// питання «що тоді зробили» лишиться.
"names": names,
"changes": in.Changes,
"filter": in.Filter,
}
// Наслідок дії над перевірками, а не лише намір. «Просив додати
// сорока, додав дванадцятьом» — це те, заради чого журнал і
// читають; із самого changes цього не видно ніяк.
if checkRes != nil {
meta["checks"] = map[string]any{
"op": checkRes.Op,
"check_type": checkRes.CheckType,
"done": checkRes.Done,
"skipped": len(checkRes.Skipped),
"failed": checkRes.Failed,
}
}
// Аудит пишеться після успіху й не блокує відповідь помилкою: якщо
// журнал не прийняв рядок, правка вже в базі, і приховувати її від
// людини було б гірше, ніж лишити слід тільки в логах сервера.
if err := s.store.WriteAudit(r.Context(), p.TenantID, store.AuditEntry{
ActorUserID: p.UserID,
ActorTokenID: p.TokenID,
ActorIP: clientIP(r),
Action: store.AuditActionDeviceBulkUpdate,
ObjectType: store.AuditObjectDevice,
Meta: meta,
}); err != nil {
s.log.Error("аудит масової правки хостів", "err", err)
}
s.log.Info("масова правка хостів",
"хостів", len(changed), "користувач", p.Username, "user_id", p.UserID)
out := map[string]any{
"updated": len(changed),
"names": names,
"dropped": dropped,
}
if checkRes != nil {
out["checks"] = checkRes
}
writeJSON(w, http.StatusOK, out)
}
// checkBulkChecksOp — запобіжники масової дії над ручними перевірками.
//
// Окремим правом вона не стала: усе те саме людина з devices:write
// робить у картці хоста поштучно (handleSetDeviceChecks), і масова
// форма економить час, а не відмикає нові можливості.
//
// Що тут перевіряється й чому саме тут:
//
// - тип із довідника. Невідомий ключ інакше доїхав би до зонда й помер
// там як задача неіснуючому модулю — за кілометри від того місця, де
// його ввели. Так само, як у handleSetDeviceChecks;
// - auto_managed нізвідки не додається й нікуди не прибирається:
// систему вже завела свій такий чек і сама тримає йому розклад, а
// прибраний вона заведе знову на найближчому такті — тобто дія
// виглядала б виконаною й нічого не міняла;
// - обов'язкові за схемою параметри мають бути заповнені. Це і є та
// межа, де масове додавання впирається в природу параметрів: url у
// http.status обов'язковий, спільного значення для сорока хостів у
// нього немає, і єдина відповідь, яка не бреше, — підстановка
// {{address}} / {{name}} / {{fqdn}}. Порожньо лишити не можна, бо
// чек без url не запуститься взагалі.
func (s *Server) checkBulkChecksOp(w http.ResponseWriter, r *http.Request, p *Principal,
op *store.BulkChecksOp) bool {
if op == nil {
return true
}
known, err := s.store.ListCheckTypes(r.Context(), p.TenantID)
if err != nil {
s.writeStoreError(w, "типи перевірок", err)
return false
}
var ct *store.CheckType
for i := range known {
if known[i].Key == op.CheckType {
ct = &known[i]
break
}
}
if ct == nil {
writeError(w, http.StatusBadRequest, "bad_check_type",
"невідомий тип перевірки: "+op.CheckType)
return false
}
if ct.AutoManaged {
writeError(w, http.StatusBadRequest, "auto_managed_check",
"перевірку «"+ct.Name+"» заводить і планує система — масово її не додають і не прибирають")
return false
}
if op.IntervalSec != nil && (*op.IntervalSec < 5 || *op.IntervalSec > 86400) {
writeError(w, http.StatusBadRequest, "bad_interval",
"інтервал опитування має бути від 5 секунд до доби")
return false
}
if op.TimeoutMs != nil && *op.TimeoutMs <= 0 {
writeError(w, http.StatusBadRequest, "bad_timeout", "таймаут має бути додатним")
return false
}
if op.Retries != nil && *op.Retries < 0 {
writeError(w, http.StatusBadRequest, "bad_retries", "повторів не може бути менше нуля")
return false
}
if op.Op != "add" {
// Прибрати чи перелаштувати треба вміти й те, що лишилось від
// вимкненого плагіна: інакше ці рядки не прибрати ніяк, а вони
// далі стоять у планах і в переліку «Черги».
return true
}
if !ct.Available {
writeError(w, http.StatusBadRequest, "plugin_disabled",
"плагін «"+ct.PluginKey+"» вимкнений — перевірку цього типу не додати")
return false
}
var schema struct {
Required []string `json:"required"`
}
_ = json.Unmarshal(ct.ParamsSchema, &schema)
if len(schema.Required) == 0 {
return true
}
var params map[string]any
if len(op.Params) > 0 {
if err := json.Unmarshal(op.Params, &params); err != nil {
writeError(w, http.StatusBadRequest, "bad_params",
"параметри перевірки мають бути об'єктом JSON")
return false
}
}
for _, k := range schema.Required {
v, ok := params[k]
if !ok || v == nil {
writeError(w, http.StatusBadRequest, "missing_param",
"параметр «"+k+"» обов'язковий для "+op.CheckType)
return false
}
if str, isStr := v.(string); isStr && strings.TrimSpace(str) == "" {
writeError(w, http.StatusBadRequest, "missing_param",
"параметр «"+k+"» обов'язковий для "+op.CheckType)
return false
}
if arr, isArr := v.([]any); isArr && len(arr) == 0 {
writeError(w, http.StatusBadRequest, "missing_param",
"параметр «"+k+"» обов'язковий для "+op.CheckType)
return false
}
}
return true
}
func (s *Server) handleBulkDeleteDevices(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "devices:write") {
return
}
var in bulkDeviceBody
if !decodeBody(w, r, &in) {
return
}
targets, dropped, ok := s.resolveBulk(w, r, p, in)
if !ok {
return
}
if len(targets) == 0 {
writeError(w, http.StatusBadRequest, "no_targets",
"жоден з обраних хостів більше не доступний на запис — перегляньте перелік ще раз")
return
}
ids := make([]string, 0, len(targets))
for _, t := range targets {
ids = append(ids, t.DeviceID)
}
switch in.Mode {
case "", "archive":
s.archiveDevices(w, r, p, in, ids, dropped)
case "purge":
s.purgeDevices(w, r, p, in, targets, ids, dropped)
default:
writeError(w, http.StatusBadRequest, "bad_mode",
"невідомий спосіб видалення: "+in.Mode)
}
}
// archiveDevices — колишня поведінка: хост зникає з переліків, зібране
// лишається.
func (s *Server) archiveDevices(w http.ResponseWriter, r *http.Request, p *Principal,
in bulkDeviceBody, ids, dropped []string) {
deleted, names, err := s.store.BulkDeleteDevices(r.Context(), p.TenantID, p.Scope(), ids)
if err != nil {
s.writeStoreError(w, "масове видалення хостів", err)
return
}
if err := s.store.WriteAudit(r.Context(), p.TenantID, store.AuditEntry{
ActorUserID: p.UserID,
ActorTokenID: p.TokenID,
ActorIP: clientIP(r),
Action: store.AuditActionDeviceBulkDelete,
ObjectType: store.AuditObjectDevice,
Meta: map[string]any{
"mode": "archive",
"devices": len(deleted),
"device_ids": deleted,
"names": names,
"filter": in.Filter,
},
}); err != nil {
s.log.Error("аудит масового видалення хостів", "err", err)
}
s.log.Info("масове видалення хостів",
"хостів", len(deleted), "користувач", p.Username, "user_id", p.UserID)
writeJSON(w, http.StatusOK, map[string]any{
"mode": "archive",
"deleted": len(deleted),
"names": names,
"dropped": dropped,
})
}
// purgeDevices — видалення назавжди.
//
// Додаткове право, і воно не з обережності. У цій системі вже ухвалено,
// що масова форма не має ставати обхідним шляхом до чужого розділу: хто
// не може змінити розклад збору конфігів одному хосту, не міняє його й
// п'ятдесяти (див. handleBulkUpdateDevices). Тут те саме, лише сильніше:
// видалення однієї версії конфігу вимагає ncm:delete — окремого права
// саме тому, що архів конфігів це те, чим доводять, ЯК виглядало
// залізо в конкретний день. Дозволити стерти весь архів хоста через
// сторінку інвентарю означало б роздати ncm:delete всім, хто має
// devices:write, і не сказати про це нікому.
//
// Хост без жодної версії такої вимоги не має: там нема чого захищати, а
// зайве право означало б, що звичайний інженер не може прибрати за
// собою тестовий хост.
func (s *Server) purgeDevices(w http.ResponseWriter, r *http.Request, p *Principal,
in bulkDeviceBody, targets []store.BulkDeviceTarget, ids, dropped []string) {
withConfigs := 0
for _, t := range targets {
withConfigs += t.Configs
}
if withConfigs > 0 && !requirePerm(w, p, deletePerm) {
return
}
res, err := s.store.PurgeDevices(r.Context(), p.TenantID, p.Scope(), ids)
if err != nil {
s.writeStoreError(w, "повне видалення хостів", err)
return
}
// Аудит пише ІМЕНА Й АДРЕСИ, а не самі лише id.
//
// Після цієї дії за id не стоїть нічого: рядка немає, і жоден запит
// його вже не розкриє. Журнал, у якому лежить
// «8d1b1df8-fa6b-…видалено», відповідає на питання «чи хтось це
// робив» і не відповідає на єдине, заради якого його читають, —
// «що саме зникло».
gone := make([]map[string]any, 0, len(res.Purged))
names := make([]string, 0, len(res.Purged))
for _, d := range res.Purged {
names = append(names, d.Name)
gone = append(gone, map[string]any{
"id": d.DeviceID, "name": d.Name, "address": d.Address,
"configs": d.Configs, "series": d.Series,
"samples": d.Samples, "branches": d.Branches,
})
}
// Окремий ключ дії, а не спільний із архівним видаленням.
//
// Досі обидва режими писались як inv.device.bulk_delete, а різниця
// між «прибрано з переліку» й «немає більше ніде» лежала в
// meta.mode. Для журналу це найгірше з можливих місць: фільтр за
// дією їх не розрізняє, позначка «незворотна» стоїть однаково на
// обох, а прочитати meta можна лише розгорнувши подію по одній.
// Ключ, за яким шукають найстрашнішу дію в системі, має бути
// власним.
//
// meta.mode лишається як був: за ним уже шукають, і прибирати поле
// заради краси означало б зламати чужі збережені фільтри.
if err := s.store.WriteAudit(r.Context(), p.TenantID, store.AuditEntry{
ActorUserID: p.UserID,
ActorTokenID: p.TokenID,
ActorIP: clientIP(r),
Action: store.AuditActionDeviceBulkPurge,
ObjectType: store.AuditObjectDevice,
Meta: map[string]any{
"mode": "purge",
"devices": len(res.Purged),
"hosts": gone,
"names": names,
"configs": res.Configs,
"freed_bytes": res.FreedBytes,
"series": res.Series,
"samples": res.Samples,
"failed": res.Failed,
"filter": in.Filter,
},
}); err != nil {
s.log.Error("аудит повного видалення хостів", "err", err)
}
s.log.Info("повне видалення хостів",
"хостів", len(res.Purged), ерсій_конфігу", res.Configs,
"рядів", res.Series, "вимірювань", res.Samples,
е_вдалося", len(res.Failed),
"користувач", p.Username, "user_id", p.UserID)
writeJSON(w, http.StatusOK, map[string]any{
"mode": "purge",
"deleted": len(res.Purged),
"names": names,
"dropped": dropped,
"purged": res,
})
}
// handleBulkRestoreDevices — повернути хости з архіву.
//
// Окрема ручка, а не третій режим bulk-delete. Ручка видалення описує
// одну дію з двома ступенями суворості; повернення — дія протилежна, і
// класти її під той самий шлях означало б, що клієнт, який помилився в
// полі `mode`, робить не те, що збирався, у бік, який навіть не
// обговорювався.
//
// Право те саме, що й у решти правок інвентарю (devices:write), і
// окремого «restore» тут немає свідомо — з тієї ж причини, що й у
// масової правки: повернення не відмикає нічого, чого людина з
// devices:write не могла б зробити інакше. Зворотне теж правда й
// важливіше: якби повернення вимагало більшого права, ніж архівування,
// система дозволяла б прибрати хост і не дозволяла б виправити це тому
// самому інженерові.
//
// Права на КОЖЕН хост окремо перевіряє добір: у набір потрапляє лише
// те, що людині дозволено змінювати (Scope.Writable).
func (s *Server) handleBulkRestoreDevices(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "devices:write") {
return
}
var in bulkDeviceBody
if !decodeBody(w, r, &in) {
return
}
// Режим не питаємо в клієнта: ця ручка адресована архіву завжди, а
// добір без цього не побачив би жодної цілі.
in.Mode = "restore"
targets, dropped, ok := s.resolveBulk(w, r, p, in)
if !ok {
return
}
if len(targets) == 0 {
writeError(w, http.StatusBadRequest, "no_targets",
"жоден з обраних хостів більше не доступний на запис — перегляньте перелік ще раз")
return
}
ids := make([]string, 0, len(targets))
for _, t := range targets {
ids = append(ids, t.DeviceID)
}
res, err := s.store.RestoreDevices(r.Context(), p.TenantID, p.Scope(), ids)
if err != nil {
s.writeStoreError(w, "повернення хостів з архіву", err)
return
}
names := make([]string, 0, len(res.Restored))
// Окремо — ті, хто повернувся вимкненим або без прив'язки до зонда.
// Це і є відповідь на питання «чому хост на місці, а даних немає»,
// і в журналі вона потрібна не менше, ніж на екрані: через тиждень
// про сам факт повернення вже ніхто не згадає.
offline := []string{}
unbound := []string{}
for _, d := range res.Restored {
names = append(names, d.Name)
if !d.Enabled {
offline = append(offline, d.Name)
}
if d.SelfAgentDropped {
unbound = append(unbound, d.Name)
}
}
if err := s.store.WriteAudit(r.Context(), p.TenantID, store.AuditEntry{
ActorUserID: p.UserID,
ActorTokenID: p.TokenID,
ActorIP: clientIP(r),
Action: store.AuditActionDeviceBulkRestore,
ObjectType: store.AuditObjectDevice,
Meta: map[string]any{
"devices": len(res.Restored),
"device_ids": ids,
"names": names,
"checks": res.Checks,
"left_disabled": offline,
"unbound": unbound,
"failed": res.Failed,
},
}); err != nil {
s.log.Error("аудит повернення хостів з архіву", "err", err)
}
s.log.Info("повернення хостів з архіву",
"хостів", len(res.Restored), "перевірок", res.Checks,
е_вдалося", len(res.Failed),
"користувач", p.Username, "user_id", p.UserID)
writeJSON(w, http.StatusOK, map[string]any{
"restored": len(res.Restored),
"names": names,
"dropped": dropped,
"result": res,
})
}