Автопризначення шаблонів за sysObjectID
Some checks are pending
CI / web (push) Waiting to run
CI / server (push) Waiting to run
CI / agent (push) Waiting to run

Пристрій сам каже, що він таке, і шаблон чіпляється без натискань.
Системна група знімається тією ж SNMP-сесією, що й обхід топології:
три зайві PDU дешевші за окремий чек із власним розкладом.

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

Шаблони тільки додаються, ніколи не знімаються: автоматика знає модель
пристрою, але не знає, чому цьому хосту дали ще один шаблон руками.

DiscoveredDevice отримав device_id: зіставляти за адресою не можна —
за одним NAT кілька хостів мають ту саму адресу опитування.

Заразом дубль перевірки перестав давати «внутрішню помилку».

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
byrsapty 2026-08-25 16:29:39 +03:00
parent 3124fe3163
commit 29f440d436
12 changed files with 461 additions and 9 deletions

View file

@ -2698,3 +2698,71 @@ SQL_ASCII не кодування, а його відсутність: серв
інтерфейс вкладка поруч із «Конфіги», обидві таблиці з живими
даними, «нічого — саме це й проблема» на місці
```
## Автопризначення шаблонів за sysObjectID
### Пристрій сам каже, що він таке
`1.3.6.1.2.1.1.2.0` повертає OID виробника й моделі. Далі це питання
зіставлення, а не здогадок — і саме тут онбординг перестає бути ручною
роботою: додав сотню комутаторів, вони самі отримали свої перевірки,
графіки й тригери.
Системна група знімається **тією ж сесією**, що й обхід топології: три
зайві PDU коштують незрівнянно менше, ніж власний чек із власним
розкладом і власним підключенням.
### Збіг за префіксом, і чому саме так
Виробник кодує в sysObjectID і серію, і модель: у Cisco це
`1.3.6.1.4.1.9.1.<модель>`, і моделей там тисячі. Вимагати повного збігу
означало б заводити рядок на кожну коробку — тобто ту саму ручну роботу,
від якої тікаємо.
Довший префікс перемагає: правило на конкретну серію точніше за загальне
правило виробника. Збіг рахується на межі компонента — `1.3.6.1.4.1.9` не
ловить `1.3.6.1.4.1.99`, інакше правило для Cisco накрило б чужого
виробника з номером, що починається так само.
Дванадцять вбудованих правил: Net-SNMP, UCD, Windows, Cisco, Huawei,
MikroTik, D-Link, Eltex, HP, Netgear, Dell, Juniper.
### Тільки додаємо
Шаблони автоматика лише чіпляє, ніколи не знімає. Вона знає модель
пристрою, але не знає, чому саме цьому хосту дали ще один шаблон руками.
Порожнє поле в системній групі не стирає те, що знали: пристрій міг не
віддати частину через ACL на своєму SNMP-агенті.
### Проto довелося розширити
`DiscoveredDevice` описував знахідку сканування й не мав `device_id`.
Зіставляти за адресою не можна: за одним NAT цілком може стояти кілька
хостів із тією самою адресою опитування — на стенді це буквально так.
Додано поле 12, згенеровано buf.
### Перевірено наскрізно
Ізольований стек на власній UTF8-базі, щоб не чіпати дані стенда:
```
зонд зареєстровано, план 1 задача (topology.discover)
опитування net-snmp на 127.0.0.1
системна група sysObjectID = 1.3.6.1.4.1.8072.3.2.10
sysDescr = Linux new-ct 6.17.13-2-pve…
зіставлення 1.3.6.1.4.1.8072 → шаблон snmp-generic
сервер «шаблони призначено за sysObjectID: хостів 1, призначено 1»
наслідок план зонда виріс 1 → 3 задачі: чек шаблону створився сам
ідемпотентність 4 звіти автовиявлення — призначення рівно одне
```
### Дорогою
Дубль перевірки (той самий тип і інтервал на одному хості) давав
«внутрішню помилку» замість пояснення. Тепер 409 із текстом.
І втретє за сесію наступив на `pkill -f`: шаблон збігається з рядком
власної ssh-сесії й тихо вбиває її. Правильна форма — `pkill -x` за
іменем процесу, як і написано в коментарі до `np-restart.sh`, який я сам
колись і писав.

View file

@ -23,7 +23,7 @@ API віддає готове полотно з живими статусами,
| Мапа, топологія, телеметрія | ✅ | ✅ |
| Автовиявлення LLDP/CDP/ARP/FDB | ✅ | ✅ |
| **Користувачі, ролі, вхід** | ✅ | ✅ |
| **Шаблони опитування** | ✅ | ⚠️ є; немає автопризначення за sysObjectID, snmp.walk, тригерів у шаблоні |
| **Шаблони опитування** | ✅ | ⚠️ є, з тригерами й автопризначенням; немає snmp.walk і прототипів |
| **NCM (збір конфігів)** | ✅ | ⚠️ збір, розклад, Git, syslog і compliance є; немає відкату |
| **Керування зондом із UI** | ✅ | ✅ |
| **Алерти й сповіщення** | ✅ | ✅ |
@ -109,9 +109,12 @@ POST /api/v1/roles кастомна роль із набором
> **Додано 2026-08-24:** графіки описуються в шаблоні (шість видів,
> жорсткі межі осей), картка хоста на вкладках без інтервалів.
>
> **Лишилось:** автопризначення за `sysObjectID`, прототипи шаблонів,
> `snmp.walk` як тип елемента (таблиці з динамічним індексом), тригери
> всередині шаблону.
> **Додано 2026-08-25:** тригери описуються в шаблоні й розгортаються в
> правила сповіщень; автопризначення за `sysObjectID` — пристрій сам
> каже, що він таке, і шаблон чіпляється без жодного натискання.
>
> **Лишилось:** прототипи шаблонів і `snmp.walk` як тип елемента
> (таблиці з динамічним індексом).
## Етап 6 (початковий план)

View file

@ -100,6 +100,9 @@ type Result struct {
// прив'язані до моменту часу так, як метрики.
Neighbors []*npv1.NeighborRecord
InterfaceRecords []*npv1.InterfaceRecord
// Системна інформація про вже відомі хости: sysObjectID, sysDescr,
// sysName. Їде тим самим звітом, бо збирається тією ж сесією.
Devices []*npv1.DiscoveredDevice
// Непрозоре навантаження, яке не є метрикою (напр. http.status → тіло відповіді).
Payload []byte
}

View file

@ -0,0 +1,84 @@
package topology
import (
"context"
"strings"
"github.com/gosnmp/gosnmp"
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
"google.golang.org/protobuf/types/known/timestamppb"
)
// SNMPv2-MIB, system group. Три значення, які пристрій розповідає про
// себе сам і які не змінюються від опитування до опитування.
const (
oidSysDescr = ".1.3.6.1.2.1.1.1.0"
oidSysObjectID = ".1.3.6.1.2.1.1.2.0"
oidSysName = ".1.3.6.1.2.1.1.5.0"
)
// collectSystem знімає системну групу з уже відкритої сесії.
//
// Три OID разом із обходом топології, а не окремим чеком: сесія вже
// відкрита, і три зайві PDU коштують незрівнянно менше, ніж власне
// опитування з власним розкладом і власним підключенням.
//
// Помилка тут не зриває автовиявлення: пристрій, який не віддає system
// group, — рідкість, але не привід втратити зібраних сусідів.
func collectSystem(ctx context.Context, client *gosnmp.GoSNMP, deviceID, address string) *npv1.DiscoveredDevice {
pkt, err := client.Get([]string{oidSysDescr, oidSysObjectID, oidSysName})
if err != nil || pkt == nil {
return nil
}
out := &npv1.DiscoveredDevice{
DeviceId: deviceID,
Address: address,
SeenAt: timestamppb.Now(),
}
for _, v := range pkt.Variables {
switch strings.TrimPrefix(v.Name, ".") {
case strings.TrimPrefix(oidSysDescr, "."):
out.SysDescr = snmpString(v)
case strings.TrimPrefix(oidSysObjectID, "."):
out.SysObjectId = normalizeOID(snmpString(v))
case strings.TrimPrefix(oidSysName, "."):
out.SysName = snmpString(v)
}
}
if out.SysObjectId == "" && out.SysDescr == "" {
return nil
}
return out
}
// snmpString зводить значення до тексту.
//
// Тип залежить від того, що саме питали: sysDescr приходить октетним
// рядком, sysObjectID — типом ObjectIdentifier, і go-snmp віддає їх
// різними Go-типами.
func snmpString(v gosnmp.SnmpPDU) string {
switch x := v.Value.(type) {
case string:
return strings.TrimSpace(x)
case []byte:
return strings.TrimSpace(string(x))
default:
return ""
}
}
// normalizeOID прибирає провідну крапку.
//
// Різні агенти віддають sysObjectID то з нею, то без, а на сервері за
// цим рядком шукають збіг префікса — розбіжність у першому символі
// зробила б збіг неможливим саме там, де все інше збігається.
func normalizeOID(s string) string {
s = strings.TrimSpace(s)
s = strings.TrimPrefix(s, ".")
// Деякі агенти пишуть "iso.3.6.1..." замість "1.3.6.1...".
if rest, ok := strings.CutPrefix(s, "iso."); ok {
s = "1." + rest
}
return s
}

View file

@ -105,6 +105,12 @@ func (m *Module) Run(ctx context.Context, task module.Task) (module.Result, erro
errs []string
)
// Системна група — найдешевше, що можна взяти з цієї сесії, і
// єдине джерело для автопризначення шаблонів за sysObjectID.
if d := collectSystem(ctx, client, task.DeviceID, task.Target.Address); d != nil {
res.Devices = append(res.Devices, d)
}
// Інвентар портів потрібен першим: LLDP оперує власною нумерацією
// портів, і без ifName/ifDescr її нема на що відобразити.
ifaces, err := collectInterfaces(ctx, client, task.DeviceID)

View file

@ -154,13 +154,14 @@ func (s *Session) Credentials(deviceID string) []*npv1.Credential {
// знімків, поки немає зв'язку, немає сенсу — наступний обхід віддасть
// свіжіші дані.
func (s *Session) ReportDiscovery(res module.Result) {
if len(res.Neighbors) == 0 && len(res.InterfaceRecords) == 0 {
if len(res.Neighbors) == 0 && len(res.InterfaceRecords) == 0 && len(res.Devices) == 0 {
return
}
rep := &npv1.DiscoveryReport{
AgentId: s.cfg.AgentID,
Neighbors: res.Neighbors,
Interfaces: res.InterfaceRecords,
Devices: res.Devices,
Final: true,
StartedAt: timestamppb.Now(),
FinishedAt: timestamppb.Now(),

View file

@ -347,8 +347,12 @@ type DiscoveredDevice struct {
// Які транспорти відповіли під час сканування.
ReachableVia []Transport `protobuf:"varint,9,rep,packed,name=reachable_via,json=reachableVia,proto3,enum=netpulse.v1.Transport" json:"reachable_via,omitempty"`
// Здогад агента про тип пристрою — сервер може перевизначити.
GuessedKind string `protobuf:"bytes,10,opt,name=guessed_kind,json=guessedKind,proto3" json:"guessed_kind,omitempty"`
SeenAt *timestamppb.Timestamp `protobuf:"bytes,11,opt,name=seen_at,json=seenAt,proto3" json:"seen_at,omitempty"`
GuessedKind string `protobuf:"bytes,10,opt,name=guessed_kind,json=guessedKind,proto3" json:"guessed_kind,omitempty"`
SeenAt *timestamppb.Timestamp `protobuf:"bytes,11,opt,name=seen_at,json=seenAt,proto3" json:"seen_at,omitempty"`
// Заповнене, якщо це не знахідка сканування, а системна інформація
// про вже відомий хост. Адреси для зіставлення мало: за одним NAT
// цілком може стояти кілька хостів з тією самою адресою опитування.
DeviceId string `protobuf:"bytes,12,opt,name=device_id,json=deviceId,proto3" json:"device_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@ -460,6 +464,13 @@ func (x *DiscoveredDevice) GetSeenAt() *timestamppb.Timestamp {
return nil
}
func (x *DiscoveredDevice) GetDeviceId() string {
if x != nil {
return x.DeviceId
}
return ""
}
type DiscoveryReport struct {
state protoimpl.MessageState `protogen:"open.v1"`
AgentId string `protobuf:"bytes,1,opt,name=agent_id,json=agentId,proto3" json:"agent_id,omitempty"`
@ -694,7 +705,7 @@ const file_netpulse_v1_discovery_proto_rawDesc = "" +
"\voper_status\x18\v \x01(\tR\n" +
"operStatus\x12&\n" +
"\x0fparent_if_index\x18\f \x01(\x03R\rparentIfIndex\x12!\n" +
"\fip_addresses\x18\r \x03(\tR\vipAddresses\"\xf9\x02\n" +
"\fip_addresses\x18\r \x03(\tR\vipAddresses\"\x96\x03\n" +
"\x10DiscoveredDevice\x12\x18\n" +
"\aaddress\x18\x01 \x01(\tR\aaddress\x12\x1a\n" +
"\bhostname\x18\x02 \x01(\tR\bhostname\x12\x10\n" +
@ -707,7 +718,8 @@ const file_netpulse_v1_discovery_proto_rawDesc = "" +
"\rreachable_via\x18\t \x03(\x0e2\x16.netpulse.v1.TransportR\freachableVia\x12!\n" +
"\fguessed_kind\x18\n" +
" \x01(\tR\vguessedKind\x123\n" +
"\aseen_at\x18\v \x01(\v2\x1a.google.protobuf.TimestampR\x06seenAt\"\xc3\x03\n" +
"\aseen_at\x18\v \x01(\v2\x1a.google.protobuf.TimestampR\x06seenAt\x12\x1b\n" +
"\tdevice_id\x18\f \x01(\tR\bdeviceId\"\xc3\x03\n" +
"\x0fDiscoveryReport\x12\x19\n" +
"\bagent_id\x18\x01 \x01(\tR\aagentId\x12\x15\n" +
"\x06run_id\x18\x02 \x01(\tR\x05runId\x129\n" +

View file

@ -89,6 +89,10 @@ message DiscoveredDevice {
// Здогад агента про тип пристрою сервер може перевизначити.
string guessed_kind = 10;
google.protobuf.Timestamp seen_at = 11;
// Заповнене, якщо це не знахідка сканування, а системна інформація
// про вже відомий хост. Адреси для зіставлення мало: за одним NAT
// цілком може стояти кілька хостів з тією самою адресою опитування.
string device_id = 12;
}
// ---------------------------------------------------------------------

View file

@ -205,6 +205,16 @@ func (s *Service) ReportDiscovery(ctx context.Context, rep *npv1.DiscoveryReport
s.refreshInterfaceChecks(ctx, agent, rep)
// Системна інформація приїжджає тим самим звітом і дає найдешевший
// онбординг з можливих: пристрій сам сказав, що він таке, і шаблон
// причепився без жодного натискання.
if info, err := s.store.ApplySystemInfo(ctx, agent.TenantID, rep.GetDevices()); err != nil {
s.log.Error("системна інформація не застосувалась", "agent", agent.ID, "err", err)
} else if info.Assigned > 0 {
s.log.Info("шаблони призначено за sysObjectID",
"agent", agent.ID, "хостів", info.Described, "призначено", info.Assigned)
}
return &npv1.DiscoveryAck{
Accepted: true,
NeighborsResolved: uint32(st.NeighborsResolved),

View file

@ -107,6 +107,14 @@ func (s *Server) handleSetDeviceChecks(w http.ResponseWriter, r *http.Request, p
writeError(w, http.StatusNotFound, "not_found", "хост не знайдено")
return
}
// Той самий тип перевірки з тим самим інтервалом на одному
// хості — помилка людини, а не сервера. «Внутрішня помилка» тут
// лишає гадати, що зламалось, замість прибрати дубль.
if isUniqueViolation(err) {
writeError(w, http.StatusConflict, "duplicate_check",
"така перевірка з таким інтервалом на цьому хості вже є")
return
}
s.writeStoreError(w, "збереження перевірок", err)
return
}

View file

@ -0,0 +1,173 @@
package store
import (
"context"
"sort"
"strings"
"github.com/jackc/pgx/v5"
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
)
// AutoAssignStat — що дав один звіт із системною інформацією.
type AutoAssignStat struct {
// Скільки хостів оновили sysObjectID.
Described int
// Скільки хостів отримали новий шаблон.
Assigned int
}
// ApplySystemInfo зберігає системну групу й довішує шаблони за
// sysObjectID.
//
// Пристрій сам каже, що він таке, і далі це питання зіставлення, а не
// здогадок. Саме тут онбординг перестає бути ручною роботою: додав сотню
// комутаторів — вони самі отримали свої перевірки, графіки й тригери.
//
// Шаблони тільки ДОдаються. Прибирати те, що людина причепила руками,
// автоматика не має права: вона знає модель пристрою, але не знає, чому
// саме цьому хосту дали ще один шаблон.
func (s *Store) ApplySystemInfo(ctx context.Context, tenantID string, devices []*npv1.DiscoveredDevice) (AutoAssignStat, error) {
var stat AutoAssignStat
if len(devices) == 0 {
return stat, nil
}
// Правила читаємо один раз на звіт: їх десятки, а хостів у звіті
// може бути сотня.
rules, err := s.autoAssignRules(ctx, tenantID)
if err != nil {
return stat, err
}
for _, d := range devices {
if d.GetDeviceId() == "" {
// Знахідки сканування (без device_id) тут не обробляємо:
// заводити хост із них — окреме рішення, яке має приймати
// людина, а не побічний ефект обходу топології.
continue
}
if err := s.saveSystemInfo(ctx, tenantID, d); err != nil {
return stat, err
}
stat.Described++
tplID := matchAutoAssign(rules, d.GetSysObjectId())
if tplID == "" {
continue
}
added, err := s.attachTemplate(ctx, tenantID, d.GetDeviceId(), tplID)
if err != nil {
return stat, err
}
if added {
stat.Assigned++
}
}
return stat, nil
}
type autoAssignRule struct {
prefix string
templateID string
}
func (s *Store) autoAssignRules(ctx context.Context, tenantID string) ([]autoAssignRule, error) {
var out []autoAssignRule
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx, `
SELECT oid_prefix, template_id::text
FROM tpl.auto_assign
WHERE enabled AND (tenant_id IS NULL OR tenant_id = $1)
`, tenantID)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var r autoAssignRule
if err := rows.Scan(&r.prefix, &r.templateID); err != nil {
return err
}
out = append(out, r)
}
return rows.Err()
})
if err != nil {
return nil, err
}
// Довший префікс попереду: правило на конкретну серію точніше за
// загальне правило виробника й має вигравати, якщо задане.
sort.Slice(out, func(i, j int) bool { return len(out[i].prefix) > len(out[j].prefix) })
return out, nil
}
// matchAutoAssign шукає найточніше правило під цей OID.
//
// Збіг за префіксом на межі компонента: `1.3.6.1.4.1.9` не має ловити
// `1.3.6.1.4.1.99`, інакше правило для Cisco накрило б чужого виробника
// з номером, що починається так само.
func matchAutoAssign(rules []autoAssignRule, oid string) string {
oid = strings.TrimPrefix(strings.TrimSpace(oid), ".")
if oid == "" {
return ""
}
for _, r := range rules {
p := strings.TrimPrefix(r.prefix, ".")
if oid == p || strings.HasPrefix(oid, p+".") {
return r.templateID
}
}
return ""
}
func (s *Store) saveSystemInfo(ctx context.Context, tenantID string, d *npv1.DiscoveredDevice) error {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
// COALESCE на боці SQL: порожнє поле означає «агент не дізнався»,
// а не «стерти те, що знали». Пристрій міг не віддати частину
// системної групи через ACL на SNMP-агенті.
_, err := tx.Exec(ctx, `
UPDATE inv.devices SET
sys_object_id = COALESCE(NULLIF($3,''), sys_object_id),
sys_descr = COALESCE(NULLIF($4,''), sys_descr),
system_name = COALESCE(NULLIF($5,''), system_name),
updated_at = now()
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
`, d.GetDeviceId(), tenantID, d.GetSysObjectId(), d.GetSysDescr(), d.GetSysName())
return err
})
}
// attachTemplate чіпляє шаблон, якщо його там ще немає.
//
// Повертає, чи справді щось змінилось: реконсиляція чеків недешева, і
// ганяти її на кожному обході топології заради нічого не варто.
func (s *Store) attachTemplate(ctx context.Context, tenantID, deviceID, templateID string) (bool, error) {
var added bool
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
tag, err := tx.Exec(ctx, `
INSERT INTO tpl.device_templates (device_id, template_id, tenant_id)
VALUES ($1, $2, $3) ON CONFLICT DO NOTHING
`, deviceID, templateID, tenantID)
if err != nil {
return err
}
added = tag.RowsAffected() > 0
return nil
})
if err != nil || !added {
return false, err
}
if err := s.ReconcileDeviceTemplates(ctx, tenantID, deviceID); err != nil {
return false, err
}
// Тригери шаблону теж мають стати правилами — інакше хост збирав би
// метрики й мовчав про проблеми.
if err := s.ReconcileTemplateRules(ctx, tenantID, templateID); err != nil {
return false, err
}
return true, nil
}

View file

@ -0,0 +1,80 @@
-- =====================================================================
-- NetPulse :: 0027_sysobjectid_autoassign.sql
-- Автопризначення шаблонів за sysObjectID.
--
-- Пристрій сам каже, що він таке: `1.3.6.1.2.1.1.2.0` повертає OID
-- виробника й моделі. Далі це питання зіставлення, а не здогадок —
-- і саме так онбординг перестає бути ручною роботою: додав сотню
-- комутаторів, вони самі отримали свої перевірки, графіки й тригери.
-- =====================================================================
ALTER TABLE inv.devices
ADD COLUMN sys_object_id text,
ADD COLUMN sys_descr text;
CREATE INDEX devices_sysobjectid_idx ON inv.devices (sys_object_id)
WHERE sys_object_id IS NOT NULL;
COMMENT ON COLUMN inv.devices.sys_object_id IS
'OID виробника з sysObjectID.0 — джерело автопризначення шаблонів';
-- ---------------------------------------------------------------------
-- Правила зіставлення
-- ---------------------------------------------------------------------
-- Збіг за ПРЕФІКСОМ, а не за повним OID.
--
-- Виробник кодує в sysObjectID і серію, і модель: у Cisco це
-- 1.3.6.1.4.1.9.1.<модель>, і моделей там тисячі. Вимагати повного збігу
-- означало б заводити рядок на кожну коробку — тобто ту саму ручну
-- роботу, від якої тікаємо.
--
-- Довший префікс перемагає: правило на 1.3.6.1.4.1.9.1.1745 точніше за
-- загальне 1.3.6.1.4.1.9 і має виграти, якщо задане.
CREATE TABLE tpl.auto_assign (
id uuid PRIMARY KEY DEFAULT core.new_id(),
-- NULL = вбудоване правило, спільне для всіх кабінетів.
tenant_id uuid REFERENCES core.tenants(id) ON DELETE CASCADE,
oid_prefix text NOT NULL CHECK (oid_prefix ~ '^[0-9.]+$'),
template_id uuid NOT NULL REFERENCES tpl.templates(id) ON DELETE CASCADE,
name text NOT NULL DEFAULT '',
enabled boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (tenant_id, oid_prefix, template_id)
);
CREATE INDEX tpl_auto_assign_prefix_idx ON tpl.auto_assign (oid_prefix) WHERE enabled;
ALTER TABLE tpl.auto_assign ENABLE ROW LEVEL SECURITY;
ALTER TABLE tpl.auto_assign FORCE ROW LEVEL SECURITY;
-- Та сама логіка, що для шаблонів: вбудовані видно всім, власні — своїм.
CREATE POLICY auto_assign_visible ON tpl.auto_assign
USING (tenant_id IS NULL OR tenant_id = core.current_tenant())
WITH CHECK (tenant_id = core.current_tenant());
-- ---------------------------------------------------------------------
-- Вбудовані правила
-- ---------------------------------------------------------------------
-- Прив'язуємось до наявного загального SNMP-шаблону: він збирає те, що
-- віддає будь-який пристрій з SNMP-агентом (uptime, навантаження,
-- пам'ять). Точніші шаблони під конкретні серії — справа кабінету.
INSERT INTO tpl.auto_assign (tenant_id, oid_prefix, template_id, name)
SELECT NULL, v.prefix, t.id, v.label
FROM tpl.templates t
CROSS JOIN (VALUES
('1.3.6.1.4.1.8072', 'Net-SNMP (Linux, BSD)'),
('1.3.6.1.4.1.2021', 'UCD-SNMP (Linux)'),
('1.3.6.1.4.1.311', 'Windows SNMP'),
('1.3.6.1.4.1.9', 'Cisco'),
('1.3.6.1.4.1.2011', 'Huawei'),
('1.3.6.1.4.1.14988', 'MikroTik'),
('1.3.6.1.4.1.171', 'D-Link'),
('1.3.6.1.4.1.35265', 'Eltex'),
('1.3.6.1.4.1.11', 'HP / HPE'),
('1.3.6.1.4.1.4526', 'Netgear'),
('1.3.6.1.4.1.674', 'Dell'),
('1.3.6.1.4.1.2636', 'Juniper')
) AS v(prefix, label)
WHERE t.tenant_id IS NULL AND t.key = 'snmp-generic'
ON CONFLICT DO NOTHING;