Режим NOC TV: дашборд на екрані в диспетчерській без входу
Some checks are pending
CI / web (push) Waiting to run
CI / server (push) Waiting to run
CI / agent (push) Waiting to run

Телевізор нікуди не залогиниш — зранку на стіні висітиме форма входу
замість карти мережі. Тому /tv/<токен> розгалужується до перевірки
сесії, а доступ дає токен у посиланні.

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
byrsapty 2026-08-25 15:41:22 +03:00
parent c28852df66
commit 4d1fd40e91
17 changed files with 1048 additions and 25 deletions

View file

@ -2547,3 +2547,91 @@ CI, який я написав два кроки тому, перевіряє `g
Урок: правило, яке ніхто не запускав, не виконується — воно лише
здається виконаним.
## Режим NOC TV
### Чому окрема сторінка, а не режим кабінету
Телевізор нікуди не залогиниш. Сесія протермінується, браузер оновиться,
і о шостій ранку на стіні висітиме форма входу замість карти мережі —
рівно тоді, коли на неї дивляться.
Тому `/tv/<токен>` розгалужується **до** перевірки входу, ще в точці
входу застосунку. Гілка виділена в окремий компонент: інакше набір хуків
залежав би від адреси, а React вимагає незмінного порядку.
### Що дає токен і чого не дає
Спокуса була зробити просто: видати посилання, яке мінтить сесію лише на
читання, і показувати весь кабінет через звичайне API. Це на порядок
менше коду й значно гірші наслідки — лінк, залишений у відкритому
браузері в кімнаті, куди заходять різні люди, став би ключем до всього.
Тому публічний зріз вузький і окремий:
```
GET /api/v1/tv/{token} розкладка дашборда
GET /api/v1/tv/{token}/alerts активні алерти
GET /api/v1/tv/{token}/devices хости
GET /api/v1/tv/{token}/devices/{id}/metrics лише хости з цього дашборда
```
Останній рядок — головний. Список хостів для метрик береться з описів
плиток: чужий хост за токеном не дістати навіть підбором ідентифікатора.
Неіснуючий і відкликаний токен дають однакову відповідь — різниця між
ними була б підказкою тому, хто перебирає.
### Плитки однакові, джерела різні
Замість другого комплекту плиток для телевізора — підміна джерела даних
через контекст. У кабінеті це звичайне API під сесією, на телевізорі —
вузький публічний зріз під токеном.
Алерти в цю абстракцію свідомо не входять: у кабінеті вони приходять
живим потоком WebSocket, на телевізорі — опитуванням, і зводити два
різні механізми до спільного інтерфейсу означало б програти обом.
Втрата зв'язку не гасить стіну: лишається остання картинка з позначкою,
відколи вона стара. Мовчазна застаріла картинка небезпечніша за порожню
— на неї дивляться й вірять.
### Дві помилки, знайдені дорогою
**Вбудовані тригери нічого не сповіщали.** Умову icmp я взяв із прикладу
в коментарі до 0007 — `{"op":"loss_pct >","value":20}`. Движок такої
форми ніколи не розумів: він читає `metric`, `op` і `value` окремими
полями. Кожен тік писав у журнал «невідома метрика "" для джерела icmp»,
а правило мовчало. Найгірша поломка для моніторингу: система виглядає
налаштованою, тригери на місці, а про недоступність хоста не скаже ніхто.
Виправляти 0024 не став — застосована міграція незмінна, і рант її
контрольної суми не дарма зупинив запуск, коли я спробував. Полагодило
нову форму окреме 0026; редактор тригерів тепер теж пише `metric` і `op`
роздільно, а список параметрів пінгу звірено з движком.
**База стенда виявилась у SQL_ASCII.** Спроба зберегти плитку з
кирилицею всередині jsonb дала «unsupported Unicode escape sequence».
SQL_ASCII не кодування, а його відсутність: сервер просто пропускає
байти. Тексти в колонках при цьому виглядають цілими, і саме тому
проблему помічають пізно — ламається інше: jsonb із `\uXXXX`-екранованим
не-ASCII (а так шле JSON половина HTTP-бібліотек), `lower()` і
сортування українських імен.
Виправити після наливання даних можна лише перестворенням бази, тож
перевірка стоїть перед першою міграцією, а не в README. `compose` тепер
задає `POSTGRES_INITDB_ARGS` явно.
### Перевірено наскрізно
```
видача POST /dashboards/{id}/public-link → /tv/np_tv_…
без входу дашборд 200, алерти 200, хости 200
чужий хост метрики → 404 «цього хоста немає на дашборді»
вигаданий токен → 404 «посилання недійсне» (те саме, що й відкликаний)
запис DELETE на публічний шлях → 404; видача без токена → 401
відкликання 204, старе посилання одразу 404
браузер /tv/<токен> без сесії: без меню, без форми входу,
три плитки з живими даними, лічильник алертів, годинник
кодування SQL_ASCII → міграція зупиняється з поясненням;
свіжа UTF8 → 26 міграцій
```

View file

@ -29,7 +29,7 @@ API віддає готове полотно з живими статусами,
| **Алерти й сповіщення** | ✅ | ✅ |
| **Мобільна адаптивність, PWA** | — | ⚠️ адаптив є, PWA немає |
| Веб-інтерфейс (навігація, сторінки) | — | ✅ |
| Дашборди, NOC TV | ✅ | ⚠️ дашборди є, режиму NOC TV немає |
| Дашборди, NOC TV | ✅ | |
| Білінг, ліцензії | ✅ | ❌ |
| Пакування, розгортання | — | ⚠️ образи й стек описані, жодного разу не зібрані |

View file

@ -196,3 +196,24 @@ NETPULSE_DEK=np2=<новий hex>,np1=<старий hex>
**TLS на проксі, а не в застосунку.** Прострочений сертифікат на системі,
яка сама має повідомляти про проблеми, — найгірший спосіб дізнатись про
проблему. Caddy оновлює його сам.
## Дашборд на телевізор
В інтерфейсі: **Дашборд → На телевізор → Видати посилання**. Отриману
адресу відкривають на екрані в диспетчерській — вона не потребує входу.
Телевізор нікуди не залогиниш: сесія протермінується, браузер оновиться,
і зранку на стіні висітиме форма входу замість карти мережі — рівно
тоді, коли на неї дивляться.
Що дає посилання і чого не дає:
- **тільки читання** цього дашборда: його плитки, активні алерти й
метрики тих хостів, які на ньому показані;
- метрики чужого хоста за ним не дістати навіть підбором ідентифікатора;
- решта кабінету — інвентар, конфіги, налаштування, секрети —
недоступна;
- відкликається одним рухом, старе посилання одразу мертве.
Токен показується один раз. Видати нове можна будь-коли — попереднє при
цьому перестає працювати.

View file

@ -25,6 +25,12 @@ services:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?потрібен POSTGRES_PASSWORD}
POSTGRES_DB: netpulse
TIMESCALEDB_TELEMETRY: "off"
# Без цього initdb бере кодування з локалі середовища й цілком
# може створити базу як SQL_ASCII. Тексти при цьому виглядають
# цілими, а ламається інше: jsonb із екранованим не-ASCII не
# вставляється, а lower() і сортування працюють як над латиницею.
# Виправити можна лише перестворенням бази, тож задаємо одразу.
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C.UTF-8"
command:
- postgres
- -c

View file

@ -90,6 +90,10 @@ func run() error {
}
defer conn.Release()
if err := checkEncoding(ctx, conn.Conn()); err != nil {
return err
}
if err := bootstrap(ctx, conn.Conn()); err != nil {
return err
}
@ -236,3 +240,33 @@ func apply(ctx context.Context, conn *pgx.Conn, version, checksum, body string)
}
return tx.Commit(ctx)
}
// checkEncoding зупиняє накочування на базі не в UTF-8.
//
// SQL_ASCII не кодування, а його відсутність: сервер просто пропускає
// байти. Українські тексти в колонках при цьому виглядають цілими, і
// саме тому проблему помічають пізно — коли ламається щось інше:
//
// - jsonb із не-ASCII, надісланим у вигляді \uXXXX, не вставляється
// взагалі («unsupported Unicode escape sequence»), а так шле JSON
// половина HTTP-бібліотек;
// - lower(), upper() і сортування працюють як над латиницею, тож
// пошук за іменем хоста мовчки не знаходить очевидне.
//
// Виправити це після наливання даних можна лише повним перестворенням
// бази, тому перевірка стоїть перед першою ж міграцією, а не в README.
func checkEncoding(ctx context.Context, conn *pgx.Conn) error {
var enc string
if err := conn.QueryRow(ctx,
`SELECT pg_encoding_to_char(encoding) FROM pg_database WHERE datname = current_database()`,
).Scan(&enc); err != nil {
return fmt.Errorf("кодування бази: %w", err)
}
if enc != "UTF8" {
return fmt.Errorf(
"база в кодуванні %s, потрібне UTF8.\n"+
"Перестворіть її: CREATE DATABASE ... ENCODING 'UTF8' TEMPLATE template0;\n"+
"У docker-compose за це відповідає POSTGRES_INITDB_ARGS", enc)
}
return nil
}

View file

@ -85,6 +85,15 @@ func (s *Server) Handler() http.Handler {
mux.Handle("PUT /api/v1/dashboards/{id}", s.authenticated(s.handleSaveDashboard))
mux.Handle("DELETE /api/v1/dashboards/{id}", s.authenticated(s.handleDeleteDashboard))
mux.Handle("POST /api/v1/dashboards/{id}/default", s.authenticated(s.handleSetDefaultDashboard))
mux.Handle("POST /api/v1/dashboards/{id}/public-link", s.authenticated(s.handleCreatePublicLink))
mux.Handle("DELETE /api/v1/dashboards/{id}/public-link", s.authenticated(s.handleRevokePublicLink))
// Режим NOC TV. Єдина частина API без входу — доступ дає токен у
// посиланні. Обмеження описані в tv.go.
mux.HandleFunc("GET /api/v1/tv/{token}", s.handleTVDashboard)
mux.HandleFunc("GET /api/v1/tv/{token}/alerts", s.handleTVAlerts)
mux.HandleFunc("GET /api/v1/tv/{token}/devices", s.handleTVDevices)
mux.HandleFunc("GET /api/v1/tv/{token}/devices/{device}/metrics", s.handleTVMetrics)
mux.Handle("GET /api/v1/maps", s.authenticated(s.handleListMaps))
mux.Handle("POST /api/v1/maps", s.authenticated(s.handleCreateMap))

View file

@ -0,0 +1,212 @@
package httpapi
import (
"errors"
"net/http"
"strconv"
"strings"
"time"
"github.com/netpulse/netpulse/server/internal/auth"
"github.com/netpulse/netpulse/server/internal/store"
)
// Режим NOC TV: дашборд на телевізорі в диспетчерській.
//
// Телевізор нікуди не залогиниш. Сесія протухне, браузер оновиться, і о
// шостій ранку на стіні висітиме форма входу замість карти мережі — саме
// тоді, коли на неї дивляться. Тому доступ дає токен у посиланні, а не
// вхід.
//
// Що це коштує з погляду безпеки й чому так:
//
// - токен дає ЛИШЕ читання і ЛИШЕ того, що показує цей дашборд:
// його плитки, алерти тенанта й метрики тих хостів, які на ньому
// згадані. Підбором ідентифікаторів чужий хост не дістати;
// - жодного запису, жодних налаштувань, жодних секретів;
// - посилання відкликається одним рухом, і старе одразу мертве.
//
// Ширший доступ (повноцінна сесія тільки для читання) був би простішим
// у коді й значно гіршим за наслідками: лінк, залишений у відкритому
// браузері, перетворився б на ключ до всього кабінету.
// publicDashboard — розібраний токен: кому належить і що показує.
type publicDashboard struct {
TenantID string
Dashboard *store.Dashboard
// Хости, згадані плитками. Порожня мапа означає дашборд без метрик.
Devices map[string]bool
}
func (s *Server) resolveTV(w http.ResponseWriter, r *http.Request) (*publicDashboard, bool) {
token := strings.TrimSpace(r.PathValue("token"))
tenantID, d, err := s.store.DashboardByPublicToken(r.Context(), token)
if errors.Is(err, store.ErrNotFound) {
// Однакова відповідь на неіснуючий і на відкликаний токен:
// різниця між ними — це підказка тому, хто перебирає.
writeError(w, http.StatusNotFound, "not_found", "посилання недійсне")
return nil, false
}
if err != nil {
s.log.Error("публічний дашборд", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return nil, false
}
devices, err := s.store.DashboardWidgetDevices(r.Context(), tenantID, d.ID)
if err != nil {
s.log.Error("хости публічного дашборда", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return nil, false
}
// Кеш браузера тут шкідливий: телевізор має показувати стан мережі,
// а не те, яким він був на момент відкриття вкладки.
w.Header().Set("Cache-Control", "no-store")
return &publicDashboard{TenantID: tenantID, Dashboard: d, Devices: devices}, true
}
// ---------------------------------------------------------------------
// Керування посиланням
// ---------------------------------------------------------------------
func (s *Server) handleCreatePublicLink(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "dashboards:write") {
return
}
token, err := auth.NewToken("np_tv_")
if err != nil {
s.log.Error("токен телевізора", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
if err := s.store.SetDashboardPublicToken(r.Context(), p.TenantID, r.PathValue("id"), token); err != nil {
s.writeStoreError(w, "посилання для телевізора", err)
return
}
// Шлях, а не повний URL: сервер за проксі не знає свого зовнішнього
// імені, і вгадування дало б посилання, яке нікуди не веде.
writeJSON(w, http.StatusOK, map[string]any{
"token": token,
"path": "/tv/" + token,
})
}
func (s *Server) handleRevokePublicLink(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "dashboards:write") {
return
}
if err := s.store.SetDashboardPublicToken(r.Context(), p.TenantID, r.PathValue("id"), ""); err != nil {
s.writeStoreError(w, "посилання для телевізора", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ---------------------------------------------------------------------
// Публічне читання
// ---------------------------------------------------------------------
func (s *Server) handleTVDashboard(w http.ResponseWriter, r *http.Request) {
tv, ok := s.resolveTV(w, r)
if !ok {
return
}
writeJSON(w, http.StatusOK, tv.Dashboard)
}
func (s *Server) handleTVAlerts(w http.ResponseWriter, r *http.Request) {
tv, ok := s.resolveTV(w, r)
if !ok {
return
}
f := store.AlertFilter{
MinSeverity: r.URL.Query().Get("min_severity"),
States: []string{"firing", "acknowledged"},
Scope: store.Scope{Unrestricted: true},
Limit: 200,
}
alerts, err := s.store.ListAlerts(r.Context(), tv.TenantID, f)
if err != nil {
s.log.Error("алерти телевізора", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
if alerts == nil {
alerts = []store.Alert{}
}
writeJSON(w, http.StatusOK, map[string]any{"alerts": alerts})
}
func (s *Server) handleTVDevices(w http.ResponseWriter, r *http.Request) {
tv, ok := s.resolveTV(w, r)
if !ok {
return
}
devices, err := s.store.ListDevices(r.Context(), tv.TenantID, store.Scope{Unrestricted: true})
if err != nil {
s.log.Error("хости телевізора", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
writeJSON(w, http.StatusOK, map[string]any{"devices": devices})
}
func (s *Server) handleTVMetrics(w http.ResponseWriter, r *http.Request) {
tv, ok := s.resolveTV(w, r)
if !ok {
return
}
deviceID := r.PathValue("device")
if !tv.Devices[deviceID] {
// Хост, якого немає на дашборді, публічному токену не належить.
// 404, а не 403: існування чужого хоста — теж відомість.
writeError(w, http.StatusNotFound, "not_found", "цього хоста немає на дашборді")
return
}
q := r.URL.Query()
var ids []int64
for _, raw := range strings.Split(q.Get("series"), ",") {
raw = strings.TrimSpace(raw)
if raw == "" {
continue
}
id, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_series", "ідентифікатор ряду має бути числом")
return
}
ids = append(ids, id)
}
if len(ids) == 0 || len(ids) > 20 {
writeError(w, http.StatusBadRequest, "bad_series", "потрібен від одного до двадцяти рядів")
return
}
mq := store.MetricQuery{DeviceID: deviceID, SeriesIDs: ids}
d, err := parseRange(orDefault(q.Get("range"), "6h"))
if err != nil {
writeError(w, http.StatusBadRequest, "bad_range", err.Error())
return
}
mq.To = time.Now()
mq.From = mq.To.Add(-d)
res, err := s.store.QueryMetrics(r.Context(), tv.TenantID, mq)
if err != nil {
s.log.Error("метрики телевізора", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
writeJSON(w, http.StatusOK, res)
}
func orDefault(v, def string) string {
if strings.TrimSpace(v) == "" {
return def
}
return v
}

View file

@ -0,0 +1,154 @@
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
}

View file

@ -0,0 +1,45 @@
-- =====================================================================
-- NetPulse :: 0026_fix_icmp_conditions.sql
-- Умова icmp-тригера має форму {"metric":"loss_pct","op":">","value":20}.
--
-- У 0024 вбудовані тригери отримали {"op":"loss_pct >","value":99} —
-- форму зі старого прикладу в коментарі до 0007, якої движок сповіщень
-- ніколи не розумів. Він читає `metric`, `op` і `value` окремими полями,
-- тож кожен тік писав у журнал «невідома метрика "" для джерела icmp», а
-- правило мовчало.
--
-- Найгірша з можливих поломок для моніторингу: система виглядає
-- налаштованою, тригери на місці, а про недоступність хоста не скаже
-- ніхто. Помітно це стає під час першої ж аварії.
--
-- Саму 0024 не чіпаємо, хоча спокуса є: застосована міграція незмінна,
-- і рант її контрольної суми не дарма зупиняє запуск. Свіжа інсталяція
-- просто отримає обидві поспіль — 0024 покладе стару форму, 0026 одразу
-- виправить.
-- =====================================================================
-- Розбираємо злиплий рядок "поле оператор" назад на два поля. Беремо
-- лише ті записи, де поля `metric` немає: чужі й уже виправлені умови
-- не чіпаємо.
UPDATE tpl.triggers
SET condition = jsonb_build_object(
'metric', split_part(condition->>'op', ' ', 1),
'op', NULLIF(split_part(condition->>'op', ' ', 2), ''),
'value', condition->'value'
)
WHERE source = 'icmp'
AND condition ? 'op'
AND NOT (condition ? 'metric')
AND position(' ' IN (condition->>'op')) > 0;
UPDATE alr.rules
SET condition = jsonb_build_object(
'metric', split_part(condition->>'op', ' ', 1),
'op', NULLIF(split_part(condition->>'op', ' ', 2), ''),
'value', condition->'value'
),
updated_at = now()
WHERE source = 'icmp'
AND condition ? 'op'
AND NOT (condition ? 'metric')
AND position(' ' IN (condition->>'op')) > 0;

View file

@ -18,9 +18,33 @@ import { ChannelsPage } from './pages/ChannelsPage'
import { AgentsPage } from './pages/AgentsPage'
import { TeamPage } from './pages/TeamPage'
import { ProfilePage } from './pages/ProfilePage'
import { TvPage } from './pages/TvPage'
import type { Permission } from './types'
/**
* Точка входу.
*
* Розгалуження тут і без жодного хука: телевізор у диспетчерській має
* оминути відновлення сесії й форму входу. Інакше екран на стіні показав
* би логін рівно те, чого режим TV уникає.
*
* Окремий компонент, а не умова всередині App: інакше набір хуків
* залежав би від адреси, а React вимагає незмінного порядку.
*/
export default function App() {
if (window.location.pathname.startsWith('/tv/')) {
return (
<BrowserRouter>
<Routes>
<Route path="/tv/:token" element={<TvPage />} />
</Routes>
</BrowserRouter>
)
}
return <Workspace />
}
function Workspace() {
// Сесія живе поза React, бо її читає й шар API. useSyncExternalStore
// підписує компонент на неї без дублювання стану.
const me = useSyncExternalStore(session.subscribe, session.me)

View file

@ -624,6 +624,14 @@ export const api = {
setDefaultDashboard: (id: string) =>
request<void>(`/api/v1/dashboards/${id}/default`, { method: 'POST' }),
createDashboardLink: (id: string) =>
request<{ token: string; path: string }>(`/api/v1/dashboards/${id}/public-link`, {
method: 'POST',
}),
revokeDashboardLink: (id: string) =>
request<void>(`/api/v1/dashboards/${id}/public-link`, { method: 'DELETE' }),
// --- доступи до мап та іконки ---
listMapPermissions: (mapID: string) =>

View file

@ -33,10 +33,13 @@ const SEVERITIES: { key: string; label: string; tone: string }[] = [
const OPS = ['>', '>=', '<', '<=', '==', '!='] as const
// Параметри пінгу, які має сенс порівнювати з числом.
// Параметри пінгу, які має сенс порівнювати з числом. Ключі мусять
// збігатися з icmpMetrics у движку сповіщень: розійдуться — правило
// мовчатиме, а в журналі буде «невідома метрика».
const ICMP_FIELDS: { key: string; label: string; unit: string }[] = [
{ key: 'loss_pct', label: 'втрати', unit: '%' },
{ key: 'rtt_ms', label: 'затримка', unit: 'мс' },
{ key: 'rtt_avg_ms', label: 'затримка', unit: 'мс' },
{ key: 'jitter_ms', label: 'джитер', unit: 'мс' },
]
const AGGS: { key: string; label: string }[] = [
@ -204,9 +207,9 @@ export function TriggerEditor({
<div className="flex gap-2">
<select
className={inputClass}
value={icmpField(cond)}
value={String(cond.metric ?? 'loss_pct')}
disabled={readOnly}
onChange={(e) => patchCond(i, { op: `${e.target.value} ${icmpOp(cond)}` })}
onChange={(e) => patchCond(i, { metric: e.target.value })}
>
{ICMP_FIELDS.map((f) => (
<option key={f.key} value={f.key}>
@ -216,9 +219,9 @@ export function TriggerEditor({
</select>
<select
className={`${inputClass} w-20`}
value={icmpOp(cond)}
value={String(cond.op ?? '>')}
disabled={readOnly}
onChange={(e) => patchCond(i, { op: `${icmpField(cond)} ${e.target.value}` })}
onChange={(e) => patchCond(i, { op: e.target.value })}
>
{OPS.map((o) => (
<option key={o} value={o}>
@ -234,7 +237,7 @@ export function TriggerEditor({
onChange={(e) => patchCond(i, { value: Number(e.target.value) })}
/>
<span className="self-center text-xs text-slate-500">
{ICMP_FIELDS.find((f) => f.key === icmpField(cond))?.unit}
{ICMP_FIELDS.find((f) => f.key === (cond.metric ?? 'loss_pct'))?.unit}
</span>
</div>
</Field>
@ -358,7 +361,7 @@ export function TriggerEditor({
function defaultCond(source: string, metricKeys: string[]): Cond {
switch (source) {
case 'icmp':
return { op: 'loss_pct >', value: 20 }
return { metric: 'loss_pct', op: '>', value: 20 }
case 'syslog':
return { regex: '' }
case 'interface':
@ -371,15 +374,3 @@ function defaultCond(source: string, metricKeys: string[]): Cond {
return { metric_key: metricKeys[0] ?? '', agg: 'avg', window: '5m', op: '>', value: 0 }
}
}
// Умова пінгу тримає поле й оператор одним рядком ("loss_pct >"), бо так
// її розуміє движок. Форма показує їх окремо — розбираємо на льоту.
function icmpField(cond: Cond): string {
const [field] = String(cond.op ?? 'loss_pct >').split(' ')
return field || 'loss_pct'
}
function icmpOp(cond: Cond): string {
const parts = String(cond.op ?? 'loss_pct >').split(' ')
return parts[1] || '>'
}

View file

@ -0,0 +1,122 @@
import { useState } from 'react'
import { api } from '../api/client'
import { Button, ErrorNote, Modal, inputClass } from './ui'
import type { Dashboard } from '../types'
/**
* Посилання для телевізора в диспетчерській.
*
* Показує рівно те, що треба зробити: отримати адресу, відкрити її на
* телевізорі, за потреби відкликати. Токен видається один раз і більше
* ніде не зберігається на клієнті повторний запит просто видає новий,
* а старий одразу перестає працювати.
*/
export function TvLinkDialog({
dashboard,
canWrite,
onClose,
}: {
dashboard: Dashboard
canWrite: boolean
onClose: () => void
}) {
const [url, setUrl] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const [err, setErr] = useState<string | null>(null)
const [revoked, setRevoked] = useState(false)
const [copied, setCopied] = useState(false)
async function issue() {
setBusy(true)
setErr(null)
try {
const r = await api.createDashboardLink(dashboard.id)
// Адресу складаємо в браузері: сервер за проксі не знає свого
// зовнішнього імені, і вгадування дало б посилання в нікуди.
setUrl(window.location.origin + r.path)
setRevoked(false)
} catch (e) {
setErr(e instanceof Error ? e.message : String(e))
} finally {
setBusy(false)
}
}
async function revoke() {
setBusy(true)
setErr(null)
try {
await api.revokeDashboardLink(dashboard.id)
setUrl(null)
setRevoked(true)
} catch (e) {
setErr(e instanceof Error ? e.message : String(e))
} finally {
setBusy(false)
}
}
return (
<Modal title={`На телевізор: ${dashboard.name}`} onClose={onClose}>
<div className="space-y-3 text-xs leading-relaxed text-slate-400">
<p>
Телевізор нікуди не залогиниш: сесія протермінується, браузер оновиться, і зранку на
стіні висітиме форма входу. Тому екран відкривають за посиланням із токеном.
</p>
<div className="rounded border border-amber-900/50 bg-amber-950/20 p-2.5 text-amber-200">
Хто має посилання бачить цей дашборд: його плитки, активні алерти й метрики тих
хостів, які на ньому показані. Тільки читання, нічого змінити не можна. Решта кабінету
за посиланням недоступна.
</div>
{url ? (
<div className="space-y-2">
<div className="flex gap-2">
<input className={`${inputClass} font-mono text-[11px]`} readOnly value={url} />
<Button
onClick={() => {
void navigator.clipboard.writeText(url).then(() => setCopied(true))
}}
>
{copied ? 'Скопійовано' : 'Копіювати'}
</Button>
</div>
<p className="text-[11px] text-slate-600">
Збережіть його зараз: удруге те саме посилання не показати можна лише видати нове,
а старе при цьому перестане працювати.
</p>
</div>
) : (
<p className="text-slate-500">
{revoked
? 'Посилання відкликано — старий екран більше нічого не покаже.'
: 'Посилання ще не видане (або видане раніше й не збережене).'}
</p>
)}
<ErrorNote>{err}</ErrorNote>
{!canWrite && (
<p className="text-[11px] text-slate-600">
Видавати й відкликати посилання може лише той, хто має право змінювати дашборди.
</p>
)}
<div className="flex justify-end gap-2 pt-1">
<Button onClick={onClose}>Закрити</Button>
{canWrite && (
<Button kind="danger" disabled={busy} onClick={() => void revoke()}>
Відкликати
</Button>
)}
{canWrite && (
<Button kind="primary" disabled={busy} onClick={() => void issue()}>
{busy ? 'Хвилинку…' : url ? 'Видати нове' : 'Видати посилання'}
</Button>
)}
</div>
</div>
</Modal>
)
}

View file

@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import type { ReactNode } from 'react'
import { api } from '../../api/client'
import { useWidgetData } from './dataSource'
import { MetricChart, fmtValue } from '../MetricChart'
import { StatusBadge, ago } from '../ui'
import type { LiveAlerts } from '../../hooks/useAlerts'
@ -66,6 +66,7 @@ function Empty({ children }: { children: ReactNode }) {
}
function useMetrics(widget: Widget) {
const source = useWidgetData()
const [data, setData] = useState<MetricResult | null>(null)
const [err, setErr] = useState<string | null>(null)
const cfg = q(widget)
@ -74,7 +75,7 @@ function useMetrics(widget: Widget) {
useEffect(() => {
if (!cfg.device_id || !cfg.series_ids?.length) return
let alive = true
api
source
.queryMetrics(cfg.device_id, cfg.series_ids, cfg.range ?? '6h', 200)
.then((r) => alive && setData(r))
.catch((e) => alive && setErr(e instanceof Error ? e.message : String(e)))
@ -184,19 +185,20 @@ function AlertListWidget({ widget, alerts }: { widget: Widget; alerts: LiveAlert
}
function DeviceGridWidget({ widget }: { widget: Widget }) {
const source = useWidgetData()
const [devices, setDevices] = useState<DeviceSummary[]>([])
const cfg = q(widget)
useEffect(() => {
let alive = true
api
source
.listDevices()
.then((d) => alive && setDevices(d))
.catch(() => {})
return () => {
alive = false
}
}, [])
}, [source])
const list = devices.slice(0, cfg.limit ?? 60)
if (list.length === 0) return <Empty>Хостів немає</Empty>

View file

@ -0,0 +1,85 @@
import { createContext, useContext } from 'react'
import { api } from '../../api/client'
import type { DeviceSummary, MetricResult } from '../../types'
/**
* Звідки плитка бере дані.
*
* Плитки однакові в кабінеті й на телевізорі, а джерела різні: у
* кабінеті звичайне API під сесією, на телевізорі вузький публічний
* зріз під токеном із посилання. Замість двох комплектів плиток
* підмінюємо джерело.
*
* Алерти сюди не входять: вони приходять окремо (у кабінеті живим
* потоком WebSocket, на телевізорі опитуванням), і зводити два різні
* механізми до спільного інтерфейсу означало б програти обом.
*/
export interface WidgetData {
queryMetrics(
deviceID: string,
seriesIDs: number[],
range: string,
points: number,
): Promise<MetricResult>
listDevices(): Promise<DeviceSummary[]>
}
const authenticated: WidgetData = {
queryMetrics: (d, s, r, p) => api.queryMetrics(d, s, r, p),
listDevices: () => api.listDevices(),
}
const Ctx = createContext<WidgetData>(authenticated)
export function WidgetDataProvider({
value,
children,
}: {
value: WidgetData
children: React.ReactNode
}) {
return <Ctx.Provider value={value}>{children}</Ctx.Provider>
}
export function useWidgetData(): WidgetData {
return useContext(Ctx)
}
/** Джерело даних публічного дашборда: усе через токен у посиланні. */
export function tvData(token: string): WidgetData {
const base = `/api/v1/tv/${encodeURIComponent(token)}`
async function get<T>(path: string): Promise<T> {
const r = await fetch(base + path, { headers: { Accept: 'application/json' } })
if (!r.ok) {
// Тіло помилки може бути й не JSON — за проксі трапляється HTML.
// Тому текст читаємо як текст і лише пробуємо розібрати.
const text = await r.text()
let msg = text
try {
msg = (JSON.parse(text) as { error?: { message?: string } }).error?.message ?? text
} catch {
/* лишаємо як є */
}
throw new Error(msg || `HTTP ${r.status}`)
}
return (await r.json()) as T
}
return {
queryMetrics: (deviceID, seriesIDs, range, points) =>
get<MetricResult>(
`/devices/${encodeURIComponent(deviceID)}/metrics` +
`?series=${seriesIDs.join(',')}&range=${encodeURIComponent(range)}&points=${points}`,
),
listDevices: () => get<{ devices: DeviceSummary[] }>('/devices').then((r) => r.devices),
}
}
export const tvFetch = <T,>(token: string, path: string): Promise<T> =>
fetch(`/api/v1/tv/${encodeURIComponent(token)}${path}`, {
headers: { Accept: 'application/json' },
}).then(async (r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`)
return (await r.json()) as T
})

View file

@ -18,6 +18,7 @@ import {
import type { ConfirmRequest } from '../components/ui'
import { WidgetView } from '../components/widgets/WidgetView'
import { WidgetForm } from '../components/widgets/WidgetForm'
import { TvLinkDialog } from '../components/TvLinkDialog'
import type { Dashboard, Widget } from '../types'
/**
@ -30,6 +31,7 @@ import type { Dashboard, Widget } from '../types'
export function DashboardPage() {
const canWrite = session.can('dashboards:write')
const alerts = useAlerts()
const [tv, setTv] = useState(false)
const [list, setList] = useState<Dashboard[]>([])
const [current, setCurrent] = useState<Dashboard | null>(null)
@ -130,6 +132,7 @@ export function DashboardPage() {
{editing ? 'Готово' : 'Редагувати'}
</Button>
)}
{current && <Button onClick={() => setTv(true)}>На телевізор</Button>}
{canWrite && <Button onClick={() => setCreating(true)}>+ Дашборд</Button>}
</>
}
@ -247,6 +250,10 @@ export function DashboardPage() {
/>
)}
{tv && current && (
<TvLinkDialog dashboard={current} canWrite={canWrite} onClose={() => setTv(false)} />
)}
{creating && (
<DashboardForm
onClose={() => setCreating(false)}

215
web/src/pages/TvPage.tsx Normal file
View file

@ -0,0 +1,215 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useParams } from 'react-router-dom'
import { WidgetView } from '../components/widgets/WidgetView'
import { WidgetDataProvider, tvData, tvFetch } from '../components/widgets/dataSource'
import type { LiveAlerts } from '../hooks/useAlerts'
import type { Alert, AlertCounts, Dashboard } from '../types'
/**
* Дашборд на телевізорі в диспетчерській.
*
* Окрема сторінка, а не режим кабінету, і причина не в оформленні.
* Телевізор нікуди не залогиниш: сесія протухне, браузер оновиться, і о
* шостій ранку на стіні висітиме форма входу замість карти мережі
* рівно тоді, коли на неї дивляться. Тому доступ дає токен у посиланні,
* а сторінка не знає ні про сесію, ні про меню, ні про решту застосунку.
*
* Усе розраховане на погляд здалеку: без навігації, великим шрифтом, із
* власним оновленням. Ніхто не підійде натиснути «оновити».
*/
export function TvPage() {
const { token = '' } = useParams()
const source = useMemo(() => tvData(token), [token])
const [dash, setDash] = useState<Dashboard | null>(null)
const [alerts, setAlerts] = useState<Alert[]>([])
const [err, setErr] = useState<string | null>(null)
const [staleSince, setStaleSince] = useState<number | null>(null)
const load = useCallback(async () => {
try {
const [d, a] = await Promise.all([
tvFetch<Dashboard>(token, ''),
tvFetch<{ alerts: Alert[] }>(token, '/alerts'),
])
setDash(d)
setAlerts(a.alerts)
setErr(null)
setStaleSince(null)
} catch (e) {
// Помилку не показуємо замість дашборда: втратити зв'язок на
// хвилину — не привід гасити стіну. Замість цього лишаємо останню
// картинку й позначаємо, відколи вона стара.
setErr(e instanceof Error ? e.message : String(e))
setStaleSince((v) => v ?? Date.now())
}
}, [token])
useEffect(() => {
void load()
}, [load])
const refreshSec = Math.max(10, dash?.refresh_sec ?? 30)
useEffect(() => {
const t = setInterval(() => void load(), refreshSec * 1000)
return () => clearInterval(t)
}, [load, refreshSec])
// Годинник у кутку — не прикраса. Дивлячись на стіну здалеку, першим
// ділом хочуть знати, чи картинка жива взагалі; хвилини, що йдуть, —
// найдешевша відповідь.
const [now, setNow] = useState(() => new Date())
useEffect(() => {
const t = setInterval(() => setNow(new Date()), 1000)
return () => clearInterval(t)
}, [])
const live: LiveAlerts = useMemo(
() => ({
alerts,
counts: countBySeverity(alerts),
loading: false,
error: null,
reload: async () => {},
ack: async () => {},
close: async () => {},
mute: async () => {},
}),
[alerts],
)
if (!dash && err) {
return (
<div className="flex h-[100dvh] items-center justify-center bg-slate-950 p-8 text-center">
<div>
<p className="text-2xl font-semibold text-slate-200">Посилання недійсне</p>
<p className="mt-2 text-sm text-slate-500">
Його могли відкликати. Попросіть нове в розділі «Дашборд».
</p>
</div>
</div>
)
}
if (!dash) {
return (
<div className="flex h-[100dvh] items-center justify-center bg-slate-950 text-slate-600">
Завантаження
</div>
)
}
const firing = live.counts.firing
return (
<WidgetDataProvider value={source}>
<div className="flex h-[100dvh] flex-col bg-slate-950 text-slate-200">
<header className="flex shrink-0 items-center gap-4 border-b border-slate-800 px-6 py-3">
<h1 className="truncate text-2xl font-semibold tracking-tight text-slate-100">
{dash.name}
</h1>
{firing > 0 && (
<span className="rounded bg-red-900/70 px-3 py-1 text-xl font-semibold tabular-nums text-red-200">
{firing}
</span>
)}
<div className="ml-auto flex items-center gap-4">
{staleSince && (
// Мовчазна застаріла картинка небезпечніша за порожню:
// на неї дивляться й вірять.
<span className="rounded bg-amber-950/60 px-3 py-1 text-sm text-amber-300">
немає зв'язку з {new Date(staleSince).toLocaleTimeString('uk-UA')}
</span>
)}
<span className="text-2xl font-semibold tabular-nums text-slate-400">
{now.toLocaleTimeString('uk-UA', { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
</header>
<TvGrid dashboard={dash} alerts={live} />
</div>
</WidgetDataProvider>
)
}
/**
* Сітка плиток.
*
* Дванадцять колонок і потік так само, як у кабінеті: розкладка
* зберігається шириною плитки, а не координатами, і другий спосіб
* укладання дав би на стіні не те, що людина бачила під час
* налаштування.
*
* Різниця лише у висоті: рядки однакові й діляться на весь екран, бо
* дашборд на стіні мусить поміститись цілком. Прокрутки немає нікому
* її крутити.
*/
function TvGrid({ dashboard, alerts }: { dashboard: Dashboard; alerts: LiveAlerts }) {
const widgets = dashboard.widgets ?? []
// Скільки рядків вийде, якщо складати плитки по дванадцять колонок.
// Рахуємо самі, щоб задати їм рівну висоту: grid-auto-rows цього не
// вміє, а без нього остання плитка розтягується на весь залишок.
let used = 0
let rows = 1
for (const w of widgets) {
const span = Math.min(12, Math.max(2, w.grid_w))
if (used + span > 12) {
rows++
used = span
} else {
used += span
}
}
if (widgets.length === 0) {
return (
<div className="flex flex-1 items-center justify-center text-xl text-slate-600">
На цьому дашборді ще немає плиток
</div>
)
}
return (
<div
className="grid min-h-0 flex-1 gap-3 p-4"
style={{
gridTemplateColumns: 'repeat(12, minmax(0, 1fr))',
gridTemplateRows: `repeat(${rows}, minmax(0, 1fr))`,
}}
>
{widgets.map((w) => (
<section
key={w.id}
className="flex min-h-0 min-w-0 flex-col overflow-hidden rounded-lg border
border-slate-800 bg-slate-900/60 p-4"
style={{ gridColumn: `span ${Math.min(12, Math.max(2, w.grid_w))}` }}
>
{w.title && (
<h2 className="mb-2 shrink-0 truncate text-base font-medium text-slate-400">
{w.title}
</h2>
)}
<div className="min-h-0 flex-1 overflow-hidden text-base">
<WidgetView widget={w} alerts={alerts} />
</div>
</section>
))}
</div>
)
}
function countBySeverity(list: Alert[]): AlertCounts {
const c: AlertCounts = { firing: 0, acknowledged: 0, suppressed: 0, disaster: 0, high: 0 }
for (const a of list) {
if (a.state === 'acknowledged') c.acknowledged++
else if (a.state === 'suppressed') c.suppressed++
else c.firing++
if (a.severity === 'disaster') c.disaster++
if (a.severity === 'high') c.high++
}
return c
}