Netpulse_SasS/server/internal/store/inventory.go
byrsapty 96692ec9df Вхід за логіном, групи хостів і права доступу
Логін:
- core.users.username замість пошти як ідентифікатор: у мережевій
  інсталяції половина акаунтів технічні (noc, monitoring, oncall) і
  скриньки не мають узагалі. Пошта лишилась необов'язковим полем
- наявним користувачам логін виведено з пошти, збіги розведено
  суфіксом: мовчки злити admin@a.com і admin@b.com в один логін —
  це втрата акаунта, а не міграція
- сервер шукає за логіном і за поштою, тому звичка вводити email
  нікого не відхиляє

Групи (модель Zabbix):
- групи хостів і групи доступу; права read/write/deny на групу хостів
- роль каже, що вільно робити; група — над якими хостами. Інженер над
  філією та інженер над усією мережею мають однакову роль і різний
  доступ
- хто не входить у жодну групу, групами не обмежений — свідомо не
  по-заббіксівськи: там кожна нова інсталяція починається з питання
  «чому порожньо»
- заборона перемагає дозвіл, інакше її обійти додаванням у сусідню групу
- фільтр накладається в самому запиті, а не після вибірки

Хости: додавання, редагування, м'яке видалення, прив'язка до груп,
фільтр за групою в таблиці.

Мапа: створення мапи з транслітерацією slug, інспектор вузла — підпис,
значок, розмір, ширина, колір рамки, закріплення.

Виправлено за скаргами:
- перемикач вилазив на 14px за трек: у ручки не заданий left, а
  статичну позицію зсуває типове text-align: center у <button>
- сторінка правил не оновлювала «активних»: після повернення правила
  алерти піднімаються наступним тіком движка, тобто ПІСЛЯ нашого
  перечитування — бракувало підписки на живі події
- канал із секретом показувався як «секрету немає»

Живий прогін: eng у групі з read на «Доступ» бачить 2 хости замість 3,
обидва без запису, алертів 1 замість 3, редагування чужого хоста 403.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 02:00:04 +03:00

156 lines
5.5 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 store
import (
"context"
"time"
"github.com/jackc/pgx/v5"
)
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"`
// Writable каже інтерфейсу, чи показувати дії. Рахується з груп
// користувача, а не з ролі: роль дозволяє редагувати пристрої взагалі,
// групи — саме цей.
Writable bool `json:"writable"`
}
// ListDevices — інвентар для таблиці в UI.
//
// Фільтр видимості накладається в самому запиті, а не після вибірки:
// відсіювати вже прочитане означало б тягнути з БД чужі рядки й
// покладатися на те, що жоден із них не проскочить у відповідь.
func (s *Store) ListDevices(ctx context.Context, tenantID string, sc Scope) ([]DeviceSummary, error) {
var out []DeviceSummary
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx, `
SELECT d.id::text, d.name, COALESCE(host(d.address), ''), d.kind::text,
COALESCE(d.vendor,''), COALESCE(d.model,''), COALESCE(st.name,''),
d.status::text, d.enabled, d.last_seen_at, COALESCE(d.agent_id::text,''),
(SELECT count(*) FROM inv.interfaces i WHERE i.device_id = d.id)
FROM inv.devices d
LEFT JOIN inv.sites st ON st.id = d.site_id
WHERE d.tenant_id = $1 AND d.deleted_at IS NULL
AND ($2::boolean OR d.id = ANY($3::uuid[]))
ORDER BY d.name
`, tenantID, sc.Unrestricted, nonNilIDs(sc.Readable))
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var d DeviceSummary
if err := rows.Scan(&d.ID, &d.Name, &d.Address, &d.Kind, &d.Vendor, &d.Model,
&d.SiteName, &d.Status, &d.Enabled, &d.LastSeenAt, &d.AgentID,
&d.IfaceCount); err != nil {
return err
}
d.Writable = sc.CanWrite(d.ID)
d.GroupIDs = []string{}
out = append(out, d)
}
return rows.Err()
})
if err != nil {
return nil, err
}
// Групи добираємо одним запитом і розкладаємо в пам'яті: агрегат у
// головному запиті змусив би його групуватись по всіх колонках.
groups, err := s.DeviceGroupsOf(ctx, tenantID)
if err != nil {
return nil, err
}
for i := range out {
if g, ok := groups[out[i].ID]; ok {
out[i].GroupIDs = g
}
}
return out, nil
}
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"`
}
// ListAgents — стан зондів для UI.
//
// health віддається як є: там лежить зведення з останнього heartbeat,
// зокрема dropped_samples. Оператор має бачити діру в даних без
// походу в графіки самометрик.
func (s *Store) ListAgents(ctx context.Context, tenantID string) ([]AgentSummary, error) {
var out []AgentSummary
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx, `
SELECT a.id::text, a.name, a.status::text,
COALESCE(a.version,''), COALESCE(a.os,''), COALESCE(a.arch,''),
COALESCE(a.hostname,''), a.last_heartbeat_at, a.enabled_modules, a.health,
(SELECT count(*) FROM inv.devices d
WHERE d.agent_id = a.id AND d.deleted_at IS NULL)
FROM core.agents a
WHERE a.tenant_id = $1
ORDER BY a.name
`, tenantID)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var a AgentSummary
if err := rows.Scan(&a.ID, &a.Name, &a.Status, &a.Version, &a.OS, &a.Arch,
&a.Hostname, &a.LastHeartbeatAt, &a.EnabledModules, &a.Health,
&a.DeviceCount); err != nil {
return err
}
if a.EnabledModules == nil {
a.EnabledModules = []string{}
}
if a.Health == nil {
a.Health = map[string]any{}
}
out = append(out, a)
}
return rows.Err()
})
return out, err
}
// MapTenant повертає тенанта мапи — потрібно WebSocket-у, щоб не дати
// підписатись на чужу мапу за вгаданим id.
func (s *Store) MapExists(ctx context.Context, tenantID, mapID string) (bool, error) {
var exists bool
err := s.pool.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1 FROM topo.maps
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
)
`, mapID, tenantID).Scan(&exists)
return exists, err
}