From 4d1fd40e9183b30182a2698012818f5e0d4fff53 Mon Sep 17 00:00:00 2001 From: byrsapty Date: Tue, 25 Aug 2026 15:41:22 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B5=D0=B6=D0=B8=D0=BC=20NOC=20TV:=20?= =?UTF-8?q?=D0=B4=D0=B0=D1=88=D0=B1=D0=BE=D1=80=D0=B4=20=D0=BD=D0=B0=20?= =?UTF-8?q?=D0=B5=D0=BA=D1=80=D0=B0=D0=BD=D1=96=20=D0=B2=20=D0=B4=D0=B8?= =?UTF-8?q?=D1=81=D0=BF=D0=B5=D1=82=D1=87=D0=B5=D1=80=D1=81=D1=8C=D0=BA?= =?UTF-8?q?=D1=96=D0=B9=20=D0=B1=D0=B5=D0=B7=20=D0=B2=D1=85=D0=BE=D0=B4?= =?UTF-8?q?=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Телевізор нікуди не залогиниш — зранку на стіні висітиме форма входу замість карти мережі. Тому /tv/<токен> розгалужується до перевірки сесії, а доступ дає токен у посиланні. Публічний зріз навмисно вузький: розкладка, алерти, хости й метрики ЛИШЕ тих хостів, які показані на цьому дашборді. Ширший доступ був би простішим у коді й перетворив би забутий лінк на ключ до кабінету. Плитки ті самі, що в кабінеті: джерело даних підмінюється контекстом. Дорогою знайдено дві помилки. Вбудовані icmp-тригери мали умову у формі, якої движок не розуміє, і мовчали, засипаючи журнал помилками — виправлено міграцією 0026 і в редакторі. База стенда виявилась у SQL_ASCII: тепер міграція відмовляється накочуватись на не-UTF8, а compose задає кодування явно. Co-Authored-By: Claude Opus 5 --- HISTORY.md | 88 +++++++ ROADMAP.md | 2 +- deploy/README.md | 21 ++ docker-compose.yml | 6 + server/cmd/netpulse-migrate/main.go | 34 +++ server/internal/httpapi/server.go | 9 + server/internal/httpapi/tv.go | 212 +++++++++++++++++ server/internal/store/dashboards_public.go | 154 +++++++++++++ .../migrations/0026_fix_icmp_conditions.sql | 45 ++++ web/src/App.tsx | 24 ++ web/src/api/client.ts | 8 + web/src/components/TriggerEditor.tsx | 31 +-- web/src/components/TvLinkDialog.tsx | 122 ++++++++++ web/src/components/widgets/WidgetView.tsx | 10 +- web/src/components/widgets/dataSource.tsx | 85 +++++++ web/src/pages/DashboardPage.tsx | 7 + web/src/pages/TvPage.tsx | 215 ++++++++++++++++++ 17 files changed, 1048 insertions(+), 25 deletions(-) create mode 100644 server/internal/httpapi/tv.go create mode 100644 server/internal/store/dashboards_public.go create mode 100644 server/migrations/0026_fix_icmp_conditions.sql create mode 100644 web/src/components/TvLinkDialog.tsx create mode 100644 web/src/components/widgets/dataSource.tsx create mode 100644 web/src/pages/TvPage.tsx diff --git a/HISTORY.md b/HISTORY.md index 9d45dea..21025a4 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -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 міграцій +``` diff --git a/ROADMAP.md b/ROADMAP.md index 9323786..2b35db2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -29,7 +29,7 @@ API віддає готове полотно з живими статусами, | **Алерти й сповіщення** | ✅ | ✅ | | **Мобільна адаптивність, PWA** | — | ⚠️ адаптив є, PWA немає | | Веб-інтерфейс (навігація, сторінки) | — | ✅ | -| Дашборди, NOC TV | ✅ | ⚠️ дашборди є, режиму NOC TV немає | +| Дашборди, NOC TV | ✅ | ✅ | | Білінг, ліцензії | ✅ | ❌ | | Пакування, розгортання | — | ⚠️ образи й стек описані, жодного разу не зібрані | diff --git a/deploy/README.md b/deploy/README.md index 24875b0..3de940a 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -196,3 +196,24 @@ NETPULSE_DEK=np2=<новий hex>,np1=<старий hex> **TLS на проксі, а не в застосунку.** Прострочений сертифікат на системі, яка сама має повідомляти про проблеми, — найгірший спосіб дізнатись про проблему. Caddy оновлює його сам. + +## Дашборд на телевізор + +В інтерфейсі: **Дашборд → На телевізор → Видати посилання**. Отриману +адресу відкривають на екрані в диспетчерській — вона не потребує входу. + +Телевізор нікуди не залогиниш: сесія протермінується, браузер оновиться, +і зранку на стіні висітиме форма входу замість карти мережі — рівно +тоді, коли на неї дивляться. + +Що дає посилання і чого не дає: + +- **тільки читання** цього дашборда: його плитки, активні алерти й + метрики тих хостів, які на ньому показані; +- метрики чужого хоста за ним не дістати навіть підбором ідентифікатора; +- решта кабінету — інвентар, конфіги, налаштування, секрети — + недоступна; +- відкликається одним рухом, старе посилання одразу мертве. + +Токен показується один раз. Видати нове можна будь-коли — попереднє при +цьому перестає працювати. diff --git a/docker-compose.yml b/docker-compose.yml index 8a8e611..07ff46e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/server/cmd/netpulse-migrate/main.go b/server/cmd/netpulse-migrate/main.go index c42e5eb..00ddd09 100644 --- a/server/cmd/netpulse-migrate/main.go +++ b/server/cmd/netpulse-migrate/main.go @@ -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 +} diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go index aa6b854..58cb629 100644 --- a/server/internal/httpapi/server.go +++ b/server/internal/httpapi/server.go @@ -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)) diff --git a/server/internal/httpapi/tv.go b/server/internal/httpapi/tv.go new file mode 100644 index 0000000..21cae29 --- /dev/null +++ b/server/internal/httpapi/tv.go @@ -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 +} diff --git a/server/internal/store/dashboards_public.go b/server/internal/store/dashboards_public.go new file mode 100644 index 0000000..a584273 --- /dev/null +++ b/server/internal/store/dashboards_public.go @@ -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 +} diff --git a/server/migrations/0026_fix_icmp_conditions.sql b/server/migrations/0026_fix_icmp_conditions.sql new file mode 100644 index 0000000..7372cdc --- /dev/null +++ b/server/migrations/0026_fix_icmp_conditions.sql @@ -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; diff --git a/web/src/App.tsx b/web/src/App.tsx index 204cf21..9778b20 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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 ( + + + } /> + + + ) + } + return +} + +function Workspace() { // Сесія живе поза React, бо її читає й шар API. useSyncExternalStore // підписує компонент на неї без дублювання стану. const me = useSyncExternalStore(session.subscribe, session.me) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index e61b5f4..8047f2f 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -624,6 +624,14 @@ export const api = { setDefaultDashboard: (id: string) => request(`/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(`/api/v1/dashboards/${id}/public-link`, { method: 'DELETE' }), + // --- доступи до мап та іконки --- listMapPermissions: (mapID: string) => diff --git a/web/src/components/TriggerEditor.tsx b/web/src/components/TriggerEditor.tsx index a7efa95..2b3ed44 100644 --- a/web/src/components/TriggerEditor.tsx +++ b/web/src/components/TriggerEditor.tsx @@ -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({
+ +
+

+ Збережіть його зараз: удруге те саме посилання не показати — можна лише видати нове, + а старе при цьому перестане працювати. +

+ + ) : ( +

+ {revoked + ? 'Посилання відкликано — старий екран більше нічого не покаже.' + : 'Посилання ще не видане (або видане раніше й не збережене).'} +

+ )} + + {err} + + {!canWrite && ( +

+ Видавати й відкликати посилання може лише той, хто має право змінювати дашборди. +

+ )} + +
+ + {canWrite && ( + + )} + {canWrite && ( + + )} +
+ + + ) +} diff --git a/web/src/components/widgets/WidgetView.tsx b/web/src/components/widgets/WidgetView.tsx index bafa847..3ee2344 100644 --- a/web/src/components/widgets/WidgetView.tsx +++ b/web/src/components/widgets/WidgetView.tsx @@ -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(null) const [err, setErr] = useState(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([]) 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 Хостів немає diff --git a/web/src/components/widgets/dataSource.tsx b/web/src/components/widgets/dataSource.tsx new file mode 100644 index 0000000..4983e32 --- /dev/null +++ b/web/src/components/widgets/dataSource.tsx @@ -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 + listDevices(): Promise +} + +const authenticated: WidgetData = { + queryMetrics: (d, s, r, p) => api.queryMetrics(d, s, r, p), + listDevices: () => api.listDevices(), +} + +const Ctx = createContext(authenticated) + +export function WidgetDataProvider({ + value, + children, +}: { + value: WidgetData + children: React.ReactNode +}) { + return {children} +} + +export function useWidgetData(): WidgetData { + return useContext(Ctx) +} + +/** Джерело даних публічного дашборда: усе через токен у посиланні. */ +export function tvData(token: string): WidgetData { + const base = `/api/v1/tv/${encodeURIComponent(token)}` + + async function get(path: string): Promise { + 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( + `/devices/${encodeURIComponent(deviceID)}/metrics` + + `?series=${seriesIDs.join(',')}&range=${encodeURIComponent(range)}&points=${points}`, + ), + listDevices: () => get<{ devices: DeviceSummary[] }>('/devices').then((r) => r.devices), + } +} + +export const tvFetch = (token: string, path: string): Promise => + 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 + }) diff --git a/web/src/pages/DashboardPage.tsx b/web/src/pages/DashboardPage.tsx index 492bc83..8d4063a 100644 --- a/web/src/pages/DashboardPage.tsx +++ b/web/src/pages/DashboardPage.tsx @@ -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([]) const [current, setCurrent] = useState(null) @@ -130,6 +132,7 @@ export function DashboardPage() { {editing ? 'Готово' : 'Редагувати'} )} + {current && } {canWrite && } } @@ -247,6 +250,10 @@ export function DashboardPage() { /> )} + {tv && current && ( + setTv(false)} /> + )} + {creating && ( setCreating(false)} diff --git a/web/src/pages/TvPage.tsx b/web/src/pages/TvPage.tsx new file mode 100644 index 0000000..53af6fd --- /dev/null +++ b/web/src/pages/TvPage.tsx @@ -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(null) + const [alerts, setAlerts] = useState([]) + const [err, setErr] = useState(null) + const [staleSince, setStaleSince] = useState(null) + + const load = useCallback(async () => { + try { + const [d, a] = await Promise.all([ + tvFetch(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 ( +
+
+

Посилання недійсне

+

+ Його могли відкликати. Попросіть нове в розділі «Дашборд». +

+
+
+ ) + } + if (!dash) { + return ( +
+ Завантаження… +
+ ) + } + + const firing = live.counts.firing + + return ( + +
+
+

+ {dash.name} +

+ + {firing > 0 && ( + + {firing} + + )} + +
+ {staleSince && ( + // Мовчазна застаріла картинка небезпечніша за порожню: + // на неї дивляться й вірять. + + немає зв'язку з {new Date(staleSince).toLocaleTimeString('uk-UA')} + + )} + + {now.toLocaleTimeString('uk-UA', { hour: '2-digit', minute: '2-digit' })} + +
+
+ + +
+
+ ) +} + +/** + * Сітка плиток. + * + * Дванадцять колонок і потік — так само, як у кабінеті: розкладка + * зберігається шириною плитки, а не координатами, і другий спосіб + * укладання дав би на стіні не те, що людина бачила під час + * налаштування. + * + * Різниця лише у висоті: рядки однакові й діляться на весь екран, бо + * дашборд на стіні мусить поміститись цілком. Прокрутки немає — нікому + * її крутити. + */ +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 ( +
+ На цьому дашборді ще немає плиток +
+ ) + } + + return ( +
+ {widgets.map((w) => ( +
+ {w.title && ( +

+ {w.title} +

+ )} +
+ +
+
+ ))} +
+ ) +} + +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 +}