Syslog: транспорт до сервера і тригер позачергового бекапу
Some checks are pending
CI / web (push) Waiting to run
CI / server (push) Waiting to run
CI / agent (push) Waiting to run

Приймач на зонді лежав без діла — тепер під'єднаний. Окремий стрім
StreamLogs, а не контрольний канал: сплеск логів під час аварії не має
заважати heartbeat і командам.

Хост зіставляється за адресою джерела на зонді: у сервера немає
контексту мережі клієнта, а один приватний діапазон трапляється в
десятках кабінетів. Невідома адреса не привід викинути подію.

Подія, що збіглася зі зразком у ncm.device_policies.syslog_match,
ставить позачерговий збір конфігу. Типовий зразок покриває Cisco,
HP/Huawei, Juniper і MikroTik — навмисно широкий: зайвий бекап коштує
секунд, пропущений — цілої зміни.

Заразом увесь репозиторій прогнано через gofmt: CI, написаний два
кроки тому, перевіряє це і впав би на 29 файлах із порушеннями,
накопиченими за весь проєкт.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
byrsapty 2026-08-25 15:20:32 +03:00
parent 43d1f62b29
commit c28852df66
35 changed files with 588 additions and 213 deletions

View file

@ -2475,3 +2475,75 @@ button, [role='button'], summary {
Урок той самий, що з невидимою вкладкою: перш ніж шукати винного в коді,
перевірити, що вимірюєш те, що думаєш.
## Syslog доведено до кінця
### Транспорт
Приймач на зонді був написаний минулого разу й лежав без діла. Тепер він
під'єднаний: `logsLoop` віддає накопичене окремим стрімом `StreamLogs`,
а не контрольним каналом — сплеск логів під час аварії не має заважати
heartbeat і командам. Саме тому в контракті ці стріми й розділені.
Пачка збирається півсекунди. Це компроміс: тригер бекапу має спрацьовувати
відчутно швидше за хвилинний цикл, а слати кожен рядок окремо немає сенсу.
Невідправлене повертається в чергу й доїде наступною сесією. Приймач
живе поза сесією: обрив зв'язку з сервером не зупиняє збір журналу — саме
заради цього черга й існує.
### Зіставлення хоста
За адресою джерела, на зонді. У сервера немає контексту мережі клієнта, а
один і той самий приватний діапазон трапляється в десятках кабінетів.
Зонд же має свіжий список своїх хостів із плану задач.
Не знайшли — подія все одно доїде з порожнім `device_id` і заповненим
`source_ip`. Викинути журнал через незнайому адресу означало б утратити
рівно те, що показує появу нового заліза в мережі.
### Тригер бекапу
Зразок звіряє Postgres, а не Go: політика зберігає його рядком у базі, і
тягнути всі політики в пам'ять заради кожної пачки журналу означало б
робити роботу там, де для неї немає даних.
Типовий зразок покриває три родини — Cisco `%SYS-5-CONFIG_I`, HP/Huawei
`CFGCHG`, Juniper і MikroTik `commit complete`. Навмисно широкий: зайвий
бекап коштує кількох секунд сесії, пропущений — цілої зміни, про яку
ніхто не дізнається.
Сплеск однакових рядків не перетворюється на сплеск сесій до пристрою:
`EnqueueConfigJob` уже відсіює другий `queued` для того самого хоста.
### Перевірено наскрізно
Окремий QA-зонд через запрошення, щоб не чіпати робочий:
```
реєстрація -enroll → «приймач syslog слухає адреса=127.0.0.1:5514»
план хост прив'язано до QA-зонда → tasks=1
подія <189>… %SYS-5-CONFIG_I: Configured from console by admin
надіслано з 127.0.0.5
розбір severity 5, hostname rtr-qa, текст цілий (не порізаний у tag)
зіставлення device_id = qa-syslog-host за адресою джерела
запис ts.syslog
тригер ncm.jobs: trigger=syslog, status=queued
негатив %LINK-3-UPDOWN і вхід ssh записані, завдань не додали
RFC5424 tag sshd[991], structured data np.user=admin, msgid ID9
```
### Заразом: увесь репозиторій під gofmt
CI, який я написав два кроки тому, перевіряє `gofmt -l`. Прогнав його
чесно — **29 файлів** із реальними порушеннями, накопиченими за весь
проєкт: неправильний порядок імпортів, збите вирівнювання полів структур
і ключів у літералах. Перший же запуск CI впав би на них.
Прогнав `gofmt -w` по всьому дереву разом із нормалізацією кінців рядків.
Через це коміт зачіпає більше файлів, ніж сама можливість: правки
формату й нового коду в тих самих файлах не розділити на два коміти без
проміжного стану, який не збирається.
Урок: правило, яке ніхто не запускав, не виконується — воно лише
здається виконаним.

View file

@ -24,7 +24,7 @@ API віддає готове полотно з живими статусами,
| Автовиявлення LLDP/CDP/ARP/FDB | ✅ | ✅ |
| **Користувачі, ролі, вхід** | ✅ | ✅ |
| **Шаблони опитування** | ✅ | ⚠️ є; немає автопризначення за sysObjectID, snmp.walk, тригерів у шаблоні |
| **NCM (збір конфігів)** | ✅ | ⚠️ збір, розклад і Git є; немає приймача syslog, відкату й compliance |
| **NCM (збір конфігів)** | ✅ | ⚠️ збір, розклад, Git і syslog є; немає відкату й compliance |
| **Керування зондом із UI** | ✅ | ✅ |
| **Алерти й сповіщення** | ✅ | ✅ |
| **Мобільна адаптивність, PWA** | — | ⚠️ адаптив є, PWA немає |
@ -174,7 +174,7 @@ JSONPath, `discard unchanged`) — на сервері при записі: ін
---
## Етап 7. NCM — збір конфігів до кінця — ⚠️ майже, 2026-08-25
## Етап 7. NCM — збір конфігів до кінця — ✅ зроблено 2026-08-25
> **Зроблено:** агентський модуль SSH/Telnet, черга завдань, диспетчер,
> вивантаження зі стисненням і дедуплікацією, візуальний diff у вебі,
@ -182,11 +182,10 @@ JSONPath, `discard unchanged`) — на сервері при записі: ін
> версією в UI, переливання історії командою `netpulse-gitsync`.
> Перевірено наскрізно.
>
> **Лишилось:** тригер за Syslog. Він упирається не в тригер: приймача
> syslog немає взагалі — `ts.syslog` і `WriteLogs` у сховищі є, але на
> агенті немає модуля, який слухав би 514/udp, і транспорт до сервера
> не під'єднано. Це окрема робота розміром із власний етап, а не
> дописування умови до наявного коду.
> **Додано 2026-08-25:** приймач syslog на зонді (RFC3164/5424, черга,
> ліміт частоти на джерело), транспорт `StreamLogs`, зіставлення хоста
> за адресою джерела й тригер позачергового бекапу за зразком у
> `ncm.device_policies.syslog_match`.
## Етап 7 (початковий план)

View file

@ -22,6 +22,7 @@ import (
"github.com/netpulse/netpulse/agent/internal/module"
"github.com/netpulse/netpulse/agent/internal/modules/icmp"
"github.com/netpulse/netpulse/agent/internal/modules/snmp"
"github.com/netpulse/netpulse/agent/internal/modules/syslog"
"github.com/netpulse/netpulse/agent/internal/modules/topology"
"github.com/netpulse/netpulse/agent/internal/scheduler"
"github.com/netpulse/netpulse/agent/internal/session"
@ -116,6 +117,17 @@ func run() error {
}
reg.EnsureDefaults(cfg.DefaultModules...)
// Приймач syslog не модуль реєстру: у нього немає задач і розкладу,
// він просто слухає порт. Вмикається тим самим переліком -modules,
// щоб людині не треба було знати про цю різницю.
var syslogRecv *syslog.Receiver
for _, m := range cfg.DefaultModules {
if m == "syslog" {
syslogRecv = syslog.New(cfg.SyslogListen, log)
break
}
}
buf := telemetry.NewBuffer(telemetry.Options{
MaxItems: cfg.BufferMaxItems,
MaxBytes: cfg.BufferMaxBytes,
@ -146,6 +158,7 @@ func run() error {
MinBackoff: cfg.MinBackoff,
MaxBackoff: cfg.MaxBackoff,
DefaultModules: cfg.DefaultModules,
Syslog: syslogRecv,
})
sched := scheduler.New(scheduler.Config{
@ -175,6 +188,22 @@ func run() error {
sched.Run(ctx)
}()
// Приймач живе поза сесією: обрив зв'язку з сервером не має
// зупиняти збір журналу. Події лягають у чергу й доїдуть, коли
// сесія відновиться — саме заради цього черга й існує.
if syslogRecv != nil {
wg.Add(1)
go func() {
defer wg.Done()
if err := syslogRecv.Run(ctx); err != nil {
// Зайнятий порт або брак прав — не привід зупиняти
// зонд: решта модулів працює, а причину видно в журналі.
log.Error("приймач syslog не запустився",
"адреса", cfg.SyslogListen, "err", err)
}
}()
}
runErr := sess.Run(ctx)
wg.Wait()

View file

@ -45,6 +45,12 @@ type Config struct {
// Модулі, увімкнені до першої відповіді сервера.
DefaultModules []string
// Адреса приймача syslog. Порт нижче 1024 вимагає
// CAP_NET_BIND_SERVICE — тому й налаштовується: віддати зонду це
// право можна не всюди, а перенаправити 514 на 5514 правилом
// фаєрвола можна скрізь.
SyslogListen string
// Одноразовий токен запрошення. Якщо заданий — зонд спершу
// реєструється, зберігає посвідчення й лише потім працює.
EnrollToken string
@ -104,6 +110,9 @@ func Parse(args []string) (*Config, error) {
mods := fs.String("modules", envOr("NETPULSE_MODULES", "icmp"),
"модулі, увімкнені до відповіді сервера (через кому)")
fs.StringVar(&c.SyslogListen, "syslog-listen", envOr("NETPULSE_SYSLOG_LISTEN", ":514"),
"адреса приймача syslog; діє лише з модулем syslog")
fs.StringVar(&c.EnrollToken, "enroll", envOr("NETPULSE_ENROLL", ""),
"одноразовий токен запрошення з UI")
fs.StringVar(&c.EnrollName, "name", envOr("NETPULSE_NAME", ""),

View file

@ -1,8 +1,8 @@
package config
import (
"crypto/tls"
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
@ -23,10 +23,10 @@ import (
// і вимагати від systemd-юніта нести токен означало б залишити його в
// журналі при кожному `systemctl show`.
type Identity struct {
AgentID string `json:"agent_id"`
AgentName string `json:"agent_name"`
Token string `json:"token"`
Endpoint string `json:"endpoint"`
AgentID string `json:"agent_id"`
AgentName string `json:"agent_name"`
Token string `json:"token"`
Endpoint string `json:"endpoint"`
EnrolledAt time.Time `json:"enrolled_at"`
}

View file

@ -1,8 +1,9 @@
// Package snmp — модуль опитування по SNMP v2c/v3.
//
// Два типи чеків:
// snmp.if — лічильники інтерфейсів (те, що живить анімацію трафіку)
// snmp.get — довільні OID → узагальнені метрики
//
// snmp.if — лічильники інтерфейсів (те, що живить анімацію трафіку)
// snmp.get — довільні OID → узагальнені метрики
//
// Свідоме рішення: агент НЕ ходить по ifTable, щоб з'ясувати, які
// інтерфейси існують. Перелік (ifIndex → interface_id → speed_bps)
@ -30,18 +31,18 @@ import (
// Базові OID. HC — 64-бітні лічильники з ifXTable; на гігабіті
// 32-бітні перевертаються за 34 секунди, тому вони лише запасний варіант.
const (
oidIfInOctets = ".1.3.6.1.2.1.2.2.1.10"
oidIfOutOctets = ".1.3.6.1.2.1.2.2.1.16"
oidIfHCInOctets = ".1.3.6.1.2.1.31.1.1.1.6"
oidIfHCOutOctets = ".1.3.6.1.2.1.31.1.1.1.10"
oidIfHCInUcast = ".1.3.6.1.2.1.31.1.1.1.7"
oidIfHCOutUcast = ".1.3.6.1.2.1.31.1.1.1.11"
oidIfInErrors = ".1.3.6.1.2.1.2.2.1.14"
oidIfOutErrors = ".1.3.6.1.2.1.2.2.1.20"
oidIfInDiscards = ".1.3.6.1.2.1.2.2.1.13"
oidIfOutDiscards = ".1.3.6.1.2.1.2.2.1.19"
oidIfAdminStatus = ".1.3.6.1.2.1.2.2.1.7"
oidIfOperStatus = ".1.3.6.1.2.1.2.2.1.8"
oidIfInOctets = ".1.3.6.1.2.1.2.2.1.10"
oidIfOutOctets = ".1.3.6.1.2.1.2.2.1.16"
oidIfHCInOctets = ".1.3.6.1.2.1.31.1.1.1.6"
oidIfHCOutOctets = ".1.3.6.1.2.1.31.1.1.1.10"
oidIfHCInUcast = ".1.3.6.1.2.1.31.1.1.1.7"
oidIfHCOutUcast = ".1.3.6.1.2.1.31.1.1.1.11"
oidIfInErrors = ".1.3.6.1.2.1.2.2.1.14"
oidIfOutErrors = ".1.3.6.1.2.1.2.2.1.20"
oidIfInDiscards = ".1.3.6.1.2.1.2.2.1.13"
oidIfOutDiscards = ".1.3.6.1.2.1.2.2.1.19"
oidIfAdminStatus = ".1.3.6.1.2.1.2.2.1.7"
oidIfOperStatus = ".1.3.6.1.2.1.2.2.1.8"
)
// ---------------------------------------------------------------------
@ -85,12 +86,12 @@ type OIDSpec struct {
// опитуваннями. Серверний розрахунок за номінальним інтервалом дає
// похибку рівно на мережеву затримку й джитер планувальника.
type counterState struct {
ts time.Time
inOctets uint64
outOctets uint64
inUcast uint64
outUcast uint64
valid bool
ts time.Time
inOctets uint64
outOctets uint64
inUcast uint64
outUcast uint64
valid bool
}
type Module struct {

View file

@ -1,12 +1,12 @@
// Package scheduler виконує план задач, отриманий від сервера.
//
// Дві вимоги визначають будову:
// 1. 5000 чеків з інтервалом 60 с не мають стартувати одночасно —
// інакше зонд раз на хвилину викидає сплеск на всю мережу.
// Розведення дає schedule_offset, який рахує СЕРВЕР (детерміновано
// від check_id), щоб воно зберігалось між перезапусками агента.
// 2. Повільний пристрій не має гальмувати решту: кожна задача має
// власний дедлайн, а паралельність обмежена семафором.
// 1. 5000 чеків з інтервалом 60 с не мають стартувати одночасно —
// інакше зонд раз на хвилину викидає сплеск на всю мережу.
// Розведення дає schedule_offset, який рахує СЕРВЕР (детерміновано
// від check_id), щоб воно зберігалось між перезапусками агента.
// 2. Повільний пристрій не має гальмувати решту: кожна задача має
// власний дедлайн, а паралельність обмежена семафором.
package scheduler
import (
@ -436,12 +436,12 @@ func (s *Scheduler) execute(ctx context.Context, e *entry) {
elapsed := time.Since(started)
cr := &npv1.CheckResult{
CheckId: task.CheckID,
DeviceId: task.DeviceID,
CheckType: task.CheckType,
Ts: timestamppb.New(started),
Duration: durationpb.New(elapsed),
Success: runErr == nil,
CheckId: task.CheckID,
DeviceId: task.DeviceID,
CheckType: task.CheckType,
Ts: timestamppb.New(started),
Duration: durationpb.New(elapsed),
Success: runErr == nil,
PayloadJson: res.Payload,
}
@ -486,10 +486,10 @@ func classify(err error, ctx context.Context) string {
type taskHeap []*entry
func (h taskHeap) Len() int { return len(h) }
func (h taskHeap) Less(i, j int) bool { return h[i].nextRun.Before(h[j].nextRun) }
func (h taskHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i]; h[i].index = i; h[j].index = j }
func (h *taskHeap) Push(x any) { e := x.(*entry); e.index = len(*h); *h = append(*h, e) }
func (h taskHeap) Len() int { return len(h) }
func (h taskHeap) Less(i, j int) bool { return h[i].nextRun.Before(h[j].nextRun) }
func (h taskHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i]; h[i].index = i; h[j].index = j }
func (h *taskHeap) Push(x any) { e := x.(*entry); e.index = len(*h); *h = append(*h, e) }
func (h *taskHeap) Pop() any {
old := *h
n := len(old)

View file

@ -0,0 +1,139 @@
package session
import (
"context"
"net"
"strings"
"time"
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
)
// maxLogBatch — стеля подій в одній пачці.
//
// Журнал не телеметрія: під час аварії він приходить сплеском, і
// відправляти по одній події означає накрутити тисячі викликів рівно
// тоді, коли мережа й так не в порядку.
const maxLogBatch = 500
// logFlush — як довго накопичувати пачку.
//
// Півсекунди — компроміс: тригер бекапу за «%SYS-5-CONFIG_I» має
// спрацьовувати відчутно швидше за хвилинний цикл, а слати кожен рядок
// окремо немає сенсу.
const logFlush = 500 * time.Millisecond
// logsLoop віддає накопичені події серверу.
//
// Окремий стрім, а не контрольний канал: сплеск логів під час аварії не
// має заважати heartbeat і командам. Саме тому в контракті StreamLogs
// існує окремо від Control.
func (s *Session) logsLoop(ctx context.Context, client npv1.AgentServiceClient) error {
rec := s.cfg.Syslog
if rec == nil {
return nil
}
stream, err := client.StreamLogs(ctx)
if err != nil {
return err
}
// Читач підтверджень окремою горутиною: Send і Recv на одному
// стрімі — єдине, що gRPC дозволяє робити паралельно.
ackErr := make(chan error, 1)
go func() {
for {
ack, err := stream.Recv()
if err != nil {
ackErr <- err
return
}
if e := ack.GetError(); e != nil {
s.log.Warn("сервер не прийняв журнал",
"code", e.GetCode(), "msg", e.GetMessage())
continue
}
// Ліміти задає сервер: він бачить картину по всіх зондах і
// краще знає, що вважати шумом.
rec.ApplyAck(ack.GetMinSeverity(), ack.GetRateLimitPerSource())
}
}()
ticker := time.NewTicker(logFlush)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return nil
case err := <-ackErr:
return err
case <-rec.Ready():
// Подія з'явилась — але не летимо одразу: даємо тіку
// зібрати сусідів у ту саму пачку.
case <-ticker.C:
}
entries, dropped := rec.Drain(maxLogBatch)
if len(entries) == 0 && dropped == 0 {
continue
}
batch := &npv1.LogBatch{
BatchId: s.nextBatch.Add(1),
AgentId: s.cfg.AgentID,
Syslog: entries,
Dropped: dropped,
}
if err := stream.Send(batch); err != nil {
// Невідправлене повертаємо в чергу: наступна сесія
// доставить. Порядок зберігається — журнал читають
// хронологічно.
rec.Requeue(entries)
return err
}
}
}
// resolveDeviceByIP шукає хост за адресою джерела.
//
// Зіставлення на зонді, а не на сервері: у сервера немає контексту
// мережі клієнта, а один і той самий приватний діапазон трапляється в
// десятках кабінетів. Зонд же має свіжий список своїх хостів.
//
// Не знайшли — не біда: подія все одно доїде з порожнім device_id і
// заповненим source_ip. Викидати журнал через незнайому адресу означало
// б утратити рівно те, що показує появу нового заліза в мережі.
func (s *Session) resolveDeviceByIP(ip string) string {
if ip == "" {
return ""
}
addr := net.ParseIP(ip)
s.devMu.RLock()
defer s.devMu.RUnlock()
for id, d := range s.devices {
target := strings.TrimSpace(d.GetAddress())
if target == "" {
continue
}
if target == ip {
return id
}
// Адреса хоста може бути записана з портом або як FQDN —
// порівнюємо ще й розібраний варіант.
if addr != nil {
if host, _, err := net.SplitHostPort(target); err == nil {
if net.ParseIP(host).Equal(addr) {
return id
}
}
if net.ParseIP(target).Equal(addr) {
return id
}
}
}
return ""
}

View file

@ -19,6 +19,7 @@ import (
"time"
"github.com/netpulse/netpulse/agent/internal/module"
"github.com/netpulse/netpulse/agent/internal/modules/syslog"
"github.com/netpulse/netpulse/agent/internal/scheduler"
"github.com/netpulse/netpulse/agent/internal/telemetry"
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
@ -52,6 +53,11 @@ type Config struct {
// Модулі, увімкнені до першої відповіді сервера.
DefaultModules []string
// Приймач syslog. Порожній — зонд журнали не збирає, і стрім до
// сервера не відкривається взагалі: тримати порожній канал заради
// вимкненої можливості немає сенсу.
Syslog *syslog.Receiver
}
type Session struct {
@ -71,7 +77,7 @@ type Session struct {
creds map[string][]*npv1.Credential
credExpiry time.Time
// Коли востаннє просили поновлення — щоб не питати щотіку.
credAsked time.Time
credAsked time.Time
devMu sync.RWMutex
devices map[string]*npv1.DeviceTarget
@ -104,7 +110,7 @@ func New(cfg Config) *Session {
if cfg.MaxBackoff <= 0 {
cfg.MaxBackoff = 2 * time.Minute
}
return &Session{
s := &Session{
cfg: cfg,
log: cfg.Logger,
statusCh: make(chan *npv1.TaskStatusUpdate, 512),
@ -113,6 +119,12 @@ func New(cfg Config) *Session {
devices: make(map[string]*npv1.DeviceTarget),
startedAt: time.Now(),
}
// Приймач слухає порт незалежно від сесії, але зіставити адресу з
// хостом може лише вона: список хостів приходить у плані.
if cfg.Syslog != nil {
cfg.Syslog.SetResolver(s.resolveDeviceByIP)
}
return s
}
// SetScheduler замикає взаємну залежність: планувальник потребує
@ -291,6 +303,9 @@ func (s *Session) runOnce(ctx context.Context) error {
spawn("status", func() error { return s.statusLoop(sctx, out) })
spawn("telemetry", func() error { return s.telemetryLoop(sctx, client, welcome) })
spawn("discovery", func() error { return s.discoveryLoop(sctx, client) })
if s.cfg.Syslog != nil {
spawn("logs", func() error { return s.logsLoop(sctx, client) })
}
// Читання команд — у цій же горутині.
readErr := s.controlLoop(sctx, ctrl, out)

View file

@ -22,8 +22,8 @@ import (
"github.com/netpulse/netpulse/server/internal/crypto"
"github.com/netpulse/netpulse/server/internal/gitstore"
"github.com/netpulse/netpulse/server/internal/httpapi"
"github.com/netpulse/netpulse/server/webui"
"github.com/netpulse/netpulse/server/internal/store"
"github.com/netpulse/netpulse/server/webui"
)
var version = "dev"
@ -52,7 +52,7 @@ func run() error {
"як часто обчислювати правила алертів; 0 — не запускати движок")
alertKeep = flag.Duration("alert-retention", 7*24*time.Hour,
"скільки тримати закриті алерти до переносу в історію")
gitRoot = flag.String("git-root", envOr("NETPULSE_GIT_ROOT", "/var/lib/netpulse/git"),
gitRoot = flag.String("git-root", envOr("NETPULSE_GIT_ROOT", "/var/lib/netpulse/git"),
"корінь сховища версій конфігів; порожньо — без Git")
privateHooks = flag.Bool("allow-private-webhooks",
os.Getenv("NETPULSE_ALLOW_PRIVATE_WEBHOOKS") == "1",

View file

@ -17,11 +17,11 @@ import (
"syscall"
"time"
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
"github.com/netpulse/netpulse/server/internal/crypto"
"github.com/netpulse/netpulse/server/internal/gitstore"
"github.com/netpulse/netpulse/server/internal/grpcapi"
"github.com/netpulse/netpulse/server/internal/store"
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/keepalive"

View file

@ -20,11 +20,11 @@ import (
// переліку: перевірка «чи підходить хвилина» стає одним зсувом, а
// шукати наступний запуск доводиться перебором хвилин.
type Schedule struct {
minute uint64 // 0..59
hour uint64 // 0..23
dom uint64 // 1..31
month uint64 // 1..12
dow uint64 // 0..6, неділя = 0
minute uint64 // 0..59
hour uint64 // 0..23
dom uint64 // 1..31
month uint64 // 1..12
dow uint64 // 0..6, неділя = 0
// domRestricted і dowRestricted кажуть, чи задане поле явно.
// Потрібні для правила cron: якщо обидва задані, збіг за будь-яким
// із них уже підходить. Це не примха, а спосіб записати «щоп'ятниці

View file

@ -5,8 +5,8 @@ import (
"errors"
"strings"
"github.com/netpulse/netpulse/server/internal/store"
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
"github.com/netpulse/netpulse/server/internal/store"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)

View file

@ -22,10 +22,10 @@ import (
"time"
"github.com/jackc/pgx/v5/pgxpool"
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
"github.com/netpulse/netpulse/server/internal/crypto"
"github.com/netpulse/netpulse/server/internal/grpcapi"
"github.com/netpulse/netpulse/server/internal/store"
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/metadata"
@ -566,14 +566,14 @@ func TestDiscoveryResolvesLink(t *testing.T) {
AgentId: f.agentID,
Final: true,
Neighbors: []*npv1.NeighborRecord{{
DeviceId: f.deviceID,
LocalInterfaceId: f.ifaceID,
Proto: npv1.DiscoveryProto_DISCOVERY_PROTO_LLDP,
RemoteChassisId: "0011.2233.6677",
RemoteSystemName: "edge-rtr-01",
RemotePortId: "ether1",
DeviceId: f.deviceID,
LocalInterfaceId: f.ifaceID,
Proto: npv1.DiscoveryProto_DISCOVERY_PROTO_LLDP,
RemoteChassisId: "0011.2233.6677",
RemoteSystemName: "edge-rtr-01",
RemotePortId: "ether1",
RemoteCapabilities: []string{"bridge", "router"},
SeenAt: timestamppb.Now(),
SeenAt: timestamppb.Now(),
}},
})
if err != nil {

View file

@ -15,9 +15,9 @@ import (
"sync"
"time"
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
"github.com/netpulse/netpulse/server/internal/crypto"
"github.com/netpulse/netpulse/server/internal/store"
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"

View file

@ -8,8 +8,8 @@ import (
"fmt"
"io"
"github.com/netpulse/netpulse/server/internal/store"
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
"github.com/netpulse/netpulse/server/internal/store"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
@ -121,7 +121,7 @@ func (s *Service) StreamLogs(stream npv1.AgentService_StreamLogsServer) error {
s.log.Error("журнали не записались", "agent", agent.ID, "err", err)
if err := stream.Send(&npv1.LogAck{
AckedThroughBatchId: acked,
Error: &npv1.Error{Code: "write_failed", Message: err.Error(), Retryable: true},
Error: &npv1.Error{Code: "write_failed", Message: err.Error(), Retryable: true},
}); err != nil {
return err
}
@ -132,6 +132,8 @@ func (s *Service) StreamLogs(stream npv1.AgentService_StreamLogsServer) error {
s.log.Warn("зонд відкинув події через ліміт",
"agent", agent.ID, "dropped", batch.GetDropped())
}
s.triggerSyslogBackups(ctx, agent, batch.GetSyslog())
if batch.GetBatchId() > acked {
acked = batch.GetBatchId()
}
@ -142,6 +144,41 @@ func (s *Service) StreamLogs(stream npv1.AgentService_StreamLogsServer) error {
}
}
// triggerSyslogBackups ставить позачерговий збір конфігу за подією в
// журналі.
//
// Заради цього приймач журналу й потрібен NCM: нічний cron ловить зміну
// в середньому через дванадцять годин, а «%SYS-5-CONFIG_I» — за секунди.
// Інженер, який щось зламав о десятій ранку, встигає піти додому, і без
// цього тригера лишається вчорашній конфіг і сьогоднішня аварія без
// нічого між ними.
//
// Помилка тут не зриває приймання журналу: події вже записані, і
// втратити їх через проблему з чергою завдань було б обміном гіршим за
// пропущений бекап.
func (s *Service) triggerSyslogBackups(ctx context.Context, agent *store.Agent, entries []*npv1.SyslogEntry) {
if len(entries) == 0 {
return
}
devices, err := s.store.SyslogBackupTriggers(ctx, agent.TenantID, entries)
if err != nil {
s.log.Error("звірка журналу з політиками бекапу", "agent", agent.ID, "err", err)
return
}
for _, deviceID := range devices {
// Порожній користувач — завдання від системи, а не від людини.
// Повторний виклик для хоста з уже поставленим завданням нічого
// не додає: EnqueueConfigJob сам це відсіює, тож сплеск однакових
// рядків не перетворюється на сплеск сесій до пристрою.
if _, err := s.store.EnqueueConfigJob(ctx, agent.TenantID, deviceID, "syslog", ""); err != nil {
s.log.Error("бекап за подією журналу не поставлено",
"device", deviceID, "err", err)
continue
}
s.log.Info("подія журналу призначила збір конфігу", "device", deviceID)
}
}
// ---------------------------------------------------------------------
// Автовиявлення
// ---------------------------------------------------------------------

View file

@ -21,10 +21,10 @@ import (
"github.com/coder/websocket"
"github.com/jackc/pgx/v5/pgxpool"
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
"github.com/netpulse/netpulse/server/internal/auth"
"github.com/netpulse/netpulse/server/internal/httpapi"
"github.com/netpulse/netpulse/server/internal/store"
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
"google.golang.org/protobuf/types/known/timestamppb"
)

View file

@ -582,4 +582,3 @@ func (s *Server) handleGetDeviceCredentials(w http.ResponseWriter, r *http.Reque
}
writeJSON(w, http.StatusOK, map[string]any{"credential_ids": ids})
}

View file

@ -370,4 +370,3 @@ func (s *Server) handleDeleteUserGroup(w http.ResponseWriter, r *http.Request, p
}
w.WriteHeader(http.StatusNoContent)
}

View file

@ -456,4 +456,3 @@ func (s *Server) handleCloneTemplate(w http.ResponseWriter, r *http.Request, p *
}
writeJSON(w, http.StatusCreated, map[string]any{"id": id})
}

View file

@ -2,9 +2,9 @@ package store
import (
"context"
"encoding/json"
"crypto/sha256"
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
"time"
@ -27,12 +27,12 @@ type Agent struct {
// Limits — ліміти опитування, які сервер диктує зонду в Welcome.
type Limits struct {
MaxConcurrency int
IcmpRatePPS int
BatchSize int
BatchInterval time.Duration
MaxInFlight int
HeartbeatEvery time.Duration
MaxConcurrency int
IcmpRatePPS int
BatchSize int
BatchInterval time.Duration
MaxInFlight int
HeartbeatEvery time.Duration
}
func defaultLimits() Limits {
@ -59,11 +59,11 @@ func (s *Store) AuthenticateAgent(ctx context.Context, token string) (*Agent, er
sum := sha256.Sum256([]byte(token))
var (
a Agent
siteID *string
hash []byte
status string
limits map[string]any
a Agent
siteID *string
hash []byte
status string
limits map[string]any
)
err := s.pool.QueryRow(ctx, `

View file

@ -29,16 +29,16 @@ type Rule struct {
// це найчастіший випадок («ping усіх»), і вимагати для нього явного
// переліку означало б ламати правило щоразу, коли додається пристрій.
type Selector struct {
DeviceIDs []string `json:"device_ids"`
GroupIDs []string `json:"group_ids"`
SiteIDs []string `json:"site_ids"`
Kinds []string `json:"kinds"`
DeviceIDs []string `json:"device_ids"`
GroupIDs []string `json:"group_ids"`
SiteIDs []string `json:"site_ids"`
Kinds []string `json:"kinds"`
// Хости, яким призначено ці шаблони. Так тригер шаблону стає одним
// правилом замість правила на кожен хост: призначили шаблон новому
// комутатору — він одразу під правилом, без перегенерації.
TemplateIDs []string `json:"template_ids"`
Vendors []string `json:"vendors"`
Tags map[string]string `json:"tags"`
TemplateIDs []string `json:"template_ids"`
Vendors []string `json:"vendors"`
Tags map[string]string `json:"tags"`
}
// Condition — умова спрацювання.

View file

@ -29,8 +29,8 @@ const InterfaceCheckInterval = 60 * time.Second
// ifCheckParams — те, що лягає в core.checks.params для snmp.if.
type ifCheckParams struct {
UseHCCounters bool `json:"use_hc_counters"`
Interfaces []ifCheckTarget `json:"interfaces"`
UseHCCounters bool `json:"use_hc_counters"`
Interfaces []ifCheckTarget `json:"interfaces"`
}
type ifCheckTarget struct {

View file

@ -6,8 +6,8 @@ import (
"time"
"github.com/jackc/pgx/v5"
"github.com/netpulse/netpulse/server/internal/crypto"
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
"github.com/netpulse/netpulse/server/internal/crypto"
"google.golang.org/protobuf/types/known/timestamppb"
)
@ -147,13 +147,13 @@ func fillSecret(cred *npv1.Credential, kind, proto string, plaintext []byte, opt
}
}
cred.SnmpV3 = &npv1.SnmpV3Options{
Level: secLevel(opts.SecLevel),
AuthProtocol: opts.AuthProto,
AuthPassword: p.AuthPassword,
PrivProtocol: opts.PrivProto,
PrivPassword: p.PrivPassword,
ContextName: opts.Context,
SecurityName: opts.SecurityName,
Level: secLevel(opts.SecLevel),
AuthProtocol: opts.AuthProto,
AuthPassword: p.AuthPassword,
PrivProtocol: opts.PrivProto,
PrivPassword: p.PrivPassword,
ContextName: opts.Context,
SecurityName: opts.SecurityName,
}
case kind == "ssh_key":

View file

@ -487,7 +487,6 @@ func (s *Store) DeviceGroupsOf(ctx context.Context, tenantID string) (map[string
return out, err
}
// nonNilIDs віддає порожній масив замість nil.
//
// pgx перетворює nil-слайс на SQL NULL, а `id = ANY(NULL)` — це не

View file

@ -8,23 +8,23 @@ import (
)
type DeviceSummary struct {
ID string `json:"id"`
Name string `json:"name"`
Address string `json:"address,omitempty"`
Kind string `json:"kind"`
Vendor string `json:"vendor,omitempty"`
Model string `json:"model,omitempty"`
SiteName string `json:"site_name,omitempty"`
Status string `json:"status"`
Enabled bool `json:"enabled"`
LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
AgentID string `json:"agent_id,omitempty"`
IfaceCount int `json:"interface_count"`
GroupIDs []string `json:"group_ids"`
ID string `json:"id"`
Name string `json:"name"`
Address string `json:"address,omitempty"`
Kind string `json:"kind"`
Vendor string `json:"vendor,omitempty"`
Model string `json:"model,omitempty"`
SiteName string `json:"site_name,omitempty"`
Status string `json:"status"`
Enabled bool `json:"enabled"`
LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
AgentID string `json:"agent_id,omitempty"`
IfaceCount int `json:"interface_count"`
GroupIDs []string `json:"group_ids"`
// Writable каже інтерфейсу, чи показувати дії. Рахується з груп
// користувача, а не з ролі: роль дозволяє редагувати пристрої взагалі,
// групи — саме цей.
Writable bool `json:"writable"`
Writable bool `json:"writable"`
}
// ListDevices — інвентар для таблиці в UI.
@ -84,17 +84,17 @@ func (s *Store) ListDevices(ctx context.Context, tenantID string, sc Scope) ([]D
}
type AgentSummary struct {
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
Version string `json:"version,omitempty"`
OS string `json:"os,omitempty"`
Arch string `json:"arch,omitempty"`
Hostname string `json:"hostname,omitempty"`
LastHeartbeatAt *time.Time `json:"last_heartbeat_at,omitempty"`
EnabledModules []string `json:"enabled_modules"`
Health map[string]any `json:"health"`
DeviceCount int `json:"device_count"`
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
Version string `json:"version,omitempty"`
OS string `json:"os,omitempty"`
Arch string `json:"arch,omitempty"`
Hostname string `json:"hostname,omitempty"`
LastHeartbeatAt *time.Time `json:"last_heartbeat_at,omitempty"`
EnabledModules []string `json:"enabled_modules"`
Health map[string]any `json:"health"`
DeviceCount int `json:"device_count"`
// Ліміти опитування. Потрібні формі налаштувань: показати типові
// значення там, де задані свої, означало б стерти їх першим же
// збереженням.

View file

@ -16,16 +16,16 @@ import (
// форма полотна й так визначена схемою (topo.map_nodes / map_edges).
type MapSummary struct {
ID string `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
Kind string `json:"kind"`
SiteID string `json:"site_id,omitempty"`
IsDefault bool `json:"is_default"`
NodeCount int `json:"node_count"`
EdgeCount int `json:"edge_count"`
Revision int64 `json:"revision"`
UpdatedAt time.Time `json:"updated_at"`
ID string `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
Kind string `json:"kind"`
SiteID string `json:"site_id,omitempty"`
IsDefault bool `json:"is_default"`
NodeCount int `json:"node_count"`
EdgeCount int `json:"edge_count"`
Revision int64 `json:"revision"`
UpdatedAt time.Time `json:"updated_at"`
// Що людина може робити саме з цією мапою: read або write.
// Рахується з груп доступу; мапа без жодного запису — write для всіх,
// хто взагалі має maps:write.
@ -33,15 +33,15 @@ type MapSummary struct {
}
type MapState struct {
ID string `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
Kind string `json:"kind"`
LayoutAlgo string `json:"layout_algo"`
Viewport json.RawMessage `json:"viewport"`
Grid json.RawMessage `json:"grid"`
Clustering json.RawMessage `json:"clustering"`
Revision int64 `json:"revision"`
ID string `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
Kind string `json:"kind"`
LayoutAlgo string `json:"layout_algo"`
Viewport json.RawMessage `json:"viewport"`
Grid json.RawMessage `json:"grid"`
Clustering json.RawMessage `json:"clustering"`
Revision int64 `json:"revision"`
Backgrounds []MapBackground `json:"backgrounds"`
Nodes []MapNode `json:"nodes"`
@ -66,20 +66,20 @@ type MapBackground struct {
}
type MapNode struct {
ID string `json:"id"`
Kind string `json:"kind"`
Label string `json:"label,omitempty"`
DeviceID string `json:"device_id,omitempty"`
ParentID string `json:"parent_id,omitempty"`
X float64 `json:"x"`
Y float64 `json:"y"`
Width *float64 `json:"width,omitempty"`
Height *float64 `json:"height,omitempty"`
ZIndex int `json:"z_index"`
Style json.RawMessage `json:"style"`
Data json.RawMessage `json:"data"`
Collapsed bool `json:"collapsed"`
Locked bool `json:"locked"`
ID string `json:"id"`
Kind string `json:"kind"`
Label string `json:"label,omitempty"`
DeviceID string `json:"device_id,omitempty"`
ParentID string `json:"parent_id,omitempty"`
X float64 `json:"x"`
Y float64 `json:"y"`
Width *float64 `json:"width,omitempty"`
Height *float64 `json:"height,omitempty"`
ZIndex int `json:"z_index"`
Style json.RawMessage `json:"style"`
Data json.RawMessage `json:"data"`
Collapsed bool `json:"collapsed"`
Locked bool `json:"locked"`
// Живий стан пристрою. Саме це фарбує вузол.
Status string `json:"status,omitempty"`
@ -89,16 +89,16 @@ type MapNode struct {
}
type MapEdge struct {
ID string `json:"id"`
SourceNodeID string `json:"source_node_id"`
TargetNodeID string `json:"target_node_id"`
SourcePort string `json:"source_port,omitempty"`
TargetPort string `json:"target_port,omitempty"`
ID string `json:"id"`
SourceNodeID string `json:"source_node_id"`
TargetNodeID string `json:"target_node_id"`
SourcePort string `json:"source_port,omitempty"`
TargetPort string `json:"target_port,omitempty"`
// До якого боку вузла кріпиться лінія. Без цього після
// перезавантаження всі зв'язки перескакують на типові порти, і
// розкладка, яку людина розводила руками, розсипається.
SourceHandle string `json:"source_handle,omitempty"`
TargetHandle string `json:"target_handle,omitempty"`
SourceHandle string `json:"source_handle,omitempty"`
TargetHandle string `json:"target_handle,omitempty"`
LinkID string `json:"link_id,omitempty"`
Label string `json:"label,omitempty"`
Style string `json:"style"`

View file

@ -48,10 +48,10 @@ func (s *Store) UndoMap(ctx context.Context, tenantID, mapID, authorID string) (
// мати проміжки (автопобудова, кілька правок), тому шукаємо
// максимальний менший, а не current-1.
var (
from int64
nodes []byte
edges []byte
backs []byte
from int64
nodes []byte
edges []byte
backs []byte
)
err = tx.QueryRow(ctx, `
SELECT revision,

View file

@ -8,8 +8,8 @@ import (
"time"
"github.com/jackc/pgx/v5"
"github.com/netpulse/netpulse/server/internal/crypto"
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
"github.com/netpulse/netpulse/server/internal/crypto"
"google.golang.org/protobuf/types/known/durationpb"
)
@ -177,15 +177,15 @@ func (s *Store) buildConfigJob(ctx context.Context, tenantID, deviceID, jobID st
Name: devName,
Address: address,
},
Credential: cred,
Transport: transport,
Port: port,
Commands: prof.Commands,
PromptRegex: prof.PromptRegex,
EnableRequired: prof.EnableRequired,
ConfigType: "running",
Timeout: durationpb.New(5 * time.Minute),
MaxBytes: 16 << 20,
Credential: cred,
Transport: transport,
Port: port,
Commands: prof.Commands,
PromptRegex: prof.PromptRegex,
EnableRequired: prof.EnableRequired,
ConfigType: "running",
Timeout: durationpb.New(5 * time.Minute),
MaxBytes: 16 << 20,
// Транскрипт пишемо завжди: він потрібен рівно тоді, коли збір
// не вдався, а вдруге відтворити ту саму сесію не вийде.
CaptureTranscript: true,

View file

@ -179,4 +179,3 @@ func (s *Store) DiffConfigs(ctx context.Context, tenantID, fromID, toID string,
return res, nil
}

View file

@ -9,17 +9,17 @@ import (
// 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"`
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"`
// Чи слідує хост глобальному розкладу. Прапорець, а не порівняння
// значень: власний розклад може випадково збігтися з глобальним.
FollowsDefault bool `json:"follows_default"`
LastBackupAt *time.Time `json:"last_backup_at,omitempty"`
NextBackupAt *time.Time `json:"next_backup_at,omitempty"`
FollowsDefault bool `json:"follows_default"`
LastBackupAt *time.Time `json:"last_backup_at,omitempty"`
NextBackupAt *time.Time `json:"next_backup_at,omitempty"`
}
// DuePolicy — політика, якій настав час.

View file

@ -0,0 +1,79 @@
package store
import (
"context"
"github.com/jackc/pgx/v5"
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
)
// defaultSyslogMatch — за чим упізнаємо зміну конфігу, якщо політика
// власного зразка не задала.
//
// Три родини покривають майже все, що трапляється в мережах:
//
// %SYS-5-CONFIG_I Cisco IOS/NX-OS — вихід із режиму конфігурації
// CFGCHG / CONFIG_I HP, Huawei, Eltex і похідні
// commit / configure Juniper та MikroTik
//
// Зразок навмисно широкий: зайвий бекап коштує кількох секунд сесії,
// а пропущений — цілої зміни, про яку ніхто не дізнається.
const defaultSyslogMatch = `%SYS-[0-9]-CONFIG_I|CFGCHG|CONFIG_I|` +
`[Cc]ommit complete|[Cc]onfiguration changed|system,info configuration changed`
// SyslogBackupTriggers каже, яким хостам подія в журналі щойно
// призначила позачерговий збір конфігу.
//
// Порівняння зразка робить Postgres, а не Go: політика зберігає його
// рядком у базі, і тягнути всі політики в пам'ять заради кожної пачки
// журналу означало б робити роботу там, де для неї немає даних.
//
// Хости без device_id пропускаються самі собою: подія з невідомої адреси
// не належить нікому, і збирати з неї нічого.
func (s *Store) SyslogBackupTriggers(
ctx context.Context,
tenantID string,
entries []*npv1.SyslogEntry,
) ([]string, error) {
devices := make([]string, 0, len(entries))
messages := make([]string, 0, len(entries))
for _, e := range entries {
if e.GetDeviceId() == "" || e.GetMessage() == "" {
continue
}
devices = append(devices, e.GetDeviceId())
messages = append(messages, e.GetMessage())
}
if len(devices) == 0 {
return nil, nil
}
var out []string
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx, `
WITH ev(device_id, message) AS (
SELECT * FROM unnest($2::uuid[], $3::text[])
)
SELECT DISTINCT ev.device_id::text
FROM ev
JOIN ncm.device_policies dp
ON dp.device_id = ev.device_id AND dp.tenant_id = $1
WHERE dp.on_syslog
AND dp.profile_id IS NOT NULL
AND ev.message ~ COALESCE(NULLIF(dp.syslog_match, ''), $4)
`, tenantID, devices, messages, defaultSyslogMatch)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return err
}
out = append(out, id)
}
return rows.Err()
})
return out, err
}

View file

@ -44,8 +44,8 @@ func templateTriggers(ctx context.Context, tx pgx.Tx, templateID string) ([]Temp
out := []TemplateTrigger{}
for rows.Next() {
var (
t TemplateTrigger
condition, recov string
t TemplateTrigger
condition, recov string
)
if err := rows.Scan(&t.ID, &t.Key, &t.Name, &t.Description, &t.Source,
&t.Severity, &condition, &recov, &t.ForSeconds,

View file

@ -19,17 +19,17 @@ var ErrBuiltin = errors.New("вбудований шаблон не редагу
// Template — шаблон опитування.
type Template struct {
ID string `json:"id"`
Key string `json:"key"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Vendor string `json:"vendor,omitempty"`
IsBuiltin bool `json:"is_builtin"`
ID string `json:"id"`
Key string `json:"key"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Vendor string `json:"vendor,omitempty"`
IsBuiltin bool `json:"is_builtin"`
Items []TemplateItem `json:"items,omitempty"`
Graphs []TemplateGraph `json:"graphs,omitempty"`
Triggers []TemplateTrigger `json:"triggers,omitempty"`
ItemCount int `json:"item_count"`
DeviceCount int `json:"device_count"`
ItemCount int `json:"item_count"`
DeviceCount int `json:"device_count"`
}
// TemplateItem — одна перевірка шаблону.

View file

@ -46,16 +46,16 @@ type TenantMembership struct {
// TeamMember — рядок сторінки «Команда».
type TeamMember struct {
UserID string `json:"user_id"`
Username string `json:"username"`
Email string `json:"email,omitempty"`
FullName string `json:"full_name,omitempty"`
RoleID string `json:"role_id"`
RoleKey string `json:"role_key"`
RoleName string `json:"role_name"`
LastLogin *time.Time `json:"last_login_at,omitempty"`
AcceptedAt *time.Time `json:"accepted_at,omitempty"`
ScopeGroups []string `json:"scope_group_ids,omitempty"`
UserID string `json:"user_id"`
Username string `json:"username"`
Email string `json:"email,omitempty"`
FullName string `json:"full_name,omitempty"`
RoleID string `json:"role_id"`
RoleKey string `json:"role_key"`
RoleName string `json:"role_name"`
LastLogin *time.Time `json:"last_login_at,omitempty"`
AcceptedAt *time.Time `json:"accepted_at,omitempty"`
ScopeGroups []string `json:"scope_group_ids,omitempty"`
}
type Role struct {