Логін: - 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>
156 lines
5.5 KiB
Go
156 lines
5.5 KiB
Go
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
|
||
}
|