Netpulse_SasS/server/internal/store/dashboards_public.go
byrsapty 4d1fd40e91
Some checks are pending
CI / web (push) Waiting to run
CI / server (push) Waiting to run
CI / agent (push) Waiting to run
Режим NOC TV: дашборд на екрані в диспетчерській без входу
Телевізор нікуди не залогиниш — зранку на стіні висітиме форма входу
замість карти мережі. Тому /tv/<токен> розгалужується до перевірки
сесії, а доступ дає токен у посиланні.

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

Плитки ті самі, що в кабінеті: джерело даних підмінюється контекстом.

Дорогою знайдено дві помилки. Вбудовані icmp-тригери мали умову у
формі, якої движок не розуміє, і мовчали, засипаючи журнал помилками —
виправлено міграцією 0026 і в редакторі. База стенда виявилась у
SQL_ASCII: тепер міграція відмовляється накочуватись на не-UTF8, а
compose задає кодування явно.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 15:41:22 +03:00

154 lines
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"
"encoding/json"
"github.com/jackc/pgx/v5"
)
// SetDashboardPublicToken видає або відкликає посилання для телевізора.
//
// Порожній токен відкликає: старе посилання одразу перестає працювати.
// Це головна операція безпеки для цієї можливості — лінк висить у
// відкритому браузері в кімнаті, куди заходять різні люди, і мусить
// вимикатись одним рухом.
func (s *Store) SetDashboardPublicToken(ctx context.Context, tenantID, id, token string) error {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
tag, err := tx.Exec(ctx, `
UPDATE core.dashboards
SET public_token = NULLIF($3,''), updated_at = now()
WHERE id = $1 AND tenant_id = $2
`, id, tenantID, token)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
})
}
// DashboardByPublicToken віддає дашборд разом із тенантом, якому він
// належить.
//
// Без tenantID на вході — його ще нема звідки взяти: телевізор не
// проходить вхід, і токен у посиланні є єдиним, що ідентифікує і
// дашборд, і кабінет. Саме тому пошук іде по унікальному індексу
// public_token, а знайдений tenant_id далі обмежує все, що віддається.
func (s *Store) DashboardByPublicToken(ctx context.Context, token string) (string, *Dashboard, error) {
if token == "" {
return "", nil, ErrNotFound
}
var (
tenantID string
d Dashboard
)
err := s.pool.QueryRow(ctx, `
SELECT tenant_id::text, id::text, name, slug, kind::text, refresh_sec, is_default
FROM core.dashboards WHERE public_token = $1
`, token).Scan(&tenantID, &d.ID, &d.Name, &d.Slug, &d.Kind, &d.RefreshSec, &d.IsDefault)
if err != nil {
if isNoRows(err) {
return "", nil, ErrNotFound
}
return "", nil, err
}
err = s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx, `
SELECT id::text, kind::text, COALESCE(title,''),
grid_x, grid_y, grid_w, grid_h,
COALESCE(map_id::text,''), queries::text, options::text
FROM core.dashboard_widgets
WHERE dashboard_id = $1
ORDER BY grid_y, grid_x
`, d.ID)
if err != nil {
return err
}
defer rows.Close()
d.Widgets = []Widget{}
for rows.Next() {
var w Widget
var queries, options string
if err := rows.Scan(&w.ID, &w.Kind, &w.Title, &w.GridX, &w.GridY,
&w.GridW, &w.GridH, &w.MapID, &queries, &options); err != nil {
return err
}
w.Queries = json.RawMessage(queries)
w.Options = json.RawMessage(options)
d.Widgets = append(d.Widgets, w)
}
d.WidgetCount = len(d.Widgets)
return rows.Err()
})
if err != nil {
return "", nil, err
}
return tenantID, &d, nil
}
// DashboardWidgetDevices — хости, згадані плитками дашборда.
//
// Потрібні, щоб публічний доступ не перетворився на читання всього
// інвентарю: телевізор бачить рівно ті хости, які сам показує, і жодним
// підбором ідентифікаторів більше не дістане.
func (s *Store) DashboardWidgetDevices(ctx context.Context, tenantID, dashboardID string) (map[string]bool, error) {
out := map[string]bool{}
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx, `
SELECT queries::text FROM core.dashboard_widgets WHERE dashboard_id = $1
`, dashboardID)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var raw string
if err := rows.Scan(&raw); err != nil {
return err
}
for _, id := range deviceIDsFromQueries(raw) {
out[id] = true
}
}
return rows.Err()
})
return out, err
}
// deviceIDsFromQueries дістає device_id з опису плитки.
//
// Опис зберігається як довільний jsonb — його форму знає фронтенд, а не
// сервер. Тому дістаємо м'яко: і об'єкт, і масив об'єктів, а все, чого
// не впізнали, просто не дає ідентифікаторів.
func deviceIDsFromQueries(raw string) []string {
if raw == "" || raw == "null" {
return nil
}
type q struct {
DeviceID string `json:"device_id"`
}
var one q
if err := json.Unmarshal([]byte(raw), &one); err == nil && one.DeviceID != "" {
return []string{one.DeviceID}
}
var many []q
if err := json.Unmarshal([]byte(raw), &many); err == nil {
out := make([]string, 0, len(many))
for _, x := range many {
if x.DeviceID != "" {
out = append(out, x.DeviceID)
}
}
return out
}
return nil
}