Режим NOC TV: дашборд на екрані в диспетчерській без входу
Телевізор нікуди не залогиниш — зранку на стіні висітиме форма входу замість карти мережі. Тому /tv/<токен> розгалужується до перевірки сесії, а доступ дає токен у посиланні. Публічний зріз навмисно вузький: розкладка, алерти, хости й метрики ЛИШЕ тих хостів, які показані на цьому дашборді. Ширший доступ був би простішим у коді й перетворив би забутий лінк на ключ до кабінету. Плитки ті самі, що в кабінеті: джерело даних підмінюється контекстом. Дорогою знайдено дві помилки. Вбудовані icmp-тригери мали умову у формі, якої движок не розуміє, і мовчали, засипаючи журнал помилками — виправлено міграцією 0026 і в редакторі. База стенда виявилась у SQL_ASCII: тепер міграція відмовляється накочуватись на не-UTF8, а compose задає кодування явно. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
c28852df66
commit
4d1fd40e91
17 changed files with 1048 additions and 25 deletions
88
HISTORY.md
88
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 міграцій
|
||||
```
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ API віддає готове полотно з живими статусами,
|
|||
| **Алерти й сповіщення** | ✅ | ✅ |
|
||||
| **Мобільна адаптивність, PWA** | — | ⚠️ адаптив є, PWA немає |
|
||||
| Веб-інтерфейс (навігація, сторінки) | — | ✅ |
|
||||
| Дашборди, NOC TV | ✅ | ⚠️ дашборди є, режиму NOC TV немає |
|
||||
| Дашборди, NOC TV | ✅ | ✅ |
|
||||
| Білінг, ліцензії | ✅ | ❌ |
|
||||
| Пакування, розгортання | — | ⚠️ образи й стек описані, жодного разу не зібрані |
|
||||
|
||||
|
|
|
|||
|
|
@ -196,3 +196,24 @@ NETPULSE_DEK=np2=<новий hex>,np1=<старий hex>
|
|||
**TLS на проксі, а не в застосунку.** Прострочений сертифікат на системі,
|
||||
яка сама має повідомляти про проблеми, — найгірший спосіб дізнатись про
|
||||
проблему. Caddy оновлює його сам.
|
||||
|
||||
## Дашборд на телевізор
|
||||
|
||||
В інтерфейсі: **Дашборд → На телевізор → Видати посилання**. Отриману
|
||||
адресу відкривають на екрані в диспетчерській — вона не потребує входу.
|
||||
|
||||
Телевізор нікуди не залогиниш: сесія протермінується, браузер оновиться,
|
||||
і зранку на стіні висітиме форма входу замість карти мережі — рівно
|
||||
тоді, коли на неї дивляться.
|
||||
|
||||
Що дає посилання і чого не дає:
|
||||
|
||||
- **тільки читання** цього дашборда: його плитки, активні алерти й
|
||||
метрики тих хостів, які на ньому показані;
|
||||
- метрики чужого хоста за ним не дістати навіть підбором ідентифікатора;
|
||||
- решта кабінету — інвентар, конфіги, налаштування, секрети —
|
||||
недоступна;
|
||||
- відкликається одним рухом, старе посилання одразу мертве.
|
||||
|
||||
Токен показується один раз. Видати нове можна будь-коли — попереднє при
|
||||
цьому перестає працювати.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
212
server/internal/httpapi/tv.go
Normal file
212
server/internal/httpapi/tv.go
Normal 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
|
||||
}
|
||||
154
server/internal/store/dashboards_public.go
Normal file
154
server/internal/store/dashboards_public.go
Normal 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
|
||||
}
|
||||
45
server/migrations/0026_fix_icmp_conditions.sql
Normal file
45
server/migrations/0026_fix_icmp_conditions.sql
Normal 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;
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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] || '>'
|
||||
}
|
||||
|
|
|
|||
122
web/src/components/TvLinkDialog.tsx
Normal file
122
web/src/components/TvLinkDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
85
web/src/components/widgets/dataSource.tsx
Normal file
85
web/src/components/widgets/dataSource.tsx
Normal 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
|
||||
})
|
||||
|
|
@ -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
215
web/src/pages/TvPage.tsx
Normal 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
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue