feat: IoT Admin Stats page — /iot-admin (v0.1.70)
- GET /iot-admin — HTML страница администратора (go:embed) - GET /iot-admin/stats — JSON API с данными (Bearer ADMIN_STATS_TOKEN) - Источники: Kafka consumer lag, K8s pod statuses, PostgreSQL per-tenant stats - Авторизация: ADMIN_STATS_TOKEN env var - Auto-refresh каждые 30 секунд - Nubes brand style
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
# Изменено: 2026-04-05 (добавлен IOT_PG_DSN, версия v0.1.59)
|
# Изменено: 2026-04-06 (добавлены KAFKA_BROKERS, ADMIN_STATS_TOKEN, версия v0.1.70)
|
||||||
# Деплой sless оператора в кластер.
|
# Деплой sless оператора в кластер.
|
||||||
# Состав:
|
# Состав:
|
||||||
# - ConfigMap: не-секретные env vars (S3_ENDPOINT, REGISTRY_HOST и т.д.)
|
# - ConfigMap: не-секретные env vars (S3_ENDPOINT, REGISTRY_HOST и т.д.)
|
||||||
@@ -33,6 +33,8 @@ data:
|
|||||||
# EXTERNAL_URL — если задан, URL функции = EXTERNAL_URL/fn/{namespace}/{name}
|
# EXTERNAL_URL — если задан, URL функции = EXTERNAL_URL/fn/{namespace}/{name}
|
||||||
# Позволяет обойтись без wildcard DNS *.fn.kube5s.ru
|
# Позволяет обойтись без wildcard DNS *.fn.kube5s.ru
|
||||||
EXTERNAL_URL: "https://sless.kube5s.ru"
|
EXTERNAL_URL: "https://sless.kube5s.ru"
|
||||||
|
# KAFKA_BROKERS — адрес Kafka для чтения consumer lag на странице администратора
|
||||||
|
KAFKA_BROKERS: "kafka.sless.svc.cluster.local:9092"
|
||||||
---
|
---
|
||||||
# Secret создаётся отдельно через kubectl (не коммитить секреты в git!)
|
# Secret создаётся отдельно через kubectl (не коммитить секреты в git!)
|
||||||
# Описание ключей:
|
# Описание ключей:
|
||||||
@@ -75,7 +77,7 @@ spec:
|
|||||||
- name: operator
|
- name: operator
|
||||||
# При обновлении версии оператора — менять тег здесь (не latest!)
|
# При обновлении версии оператора — менять тег здесь (не latest!)
|
||||||
# v0.1.59 — добавлено сохранение телеметрии в IoT Postgres (per-tenant DB)
|
# v0.1.59 — добавлено сохранение телеметрии в IoT Postgres (per-tenant DB)
|
||||||
image: pearlharbor.registryk8s.services.ngcloud.ru/naeel/sless-operator:v0.1.59
|
image: pearlharbor.registryk8s.services.ngcloud.ru/naeel/sless-operator:v0.1.70
|
||||||
# Always — чтобы всегда тянуть по точному тегу (не кешировать старый)
|
# Always — чтобы всегда тянуть по точному тегу (не кешировать старый)
|
||||||
imagePullPolicy: Always
|
imagePullPolicy: Always
|
||||||
ports:
|
ports:
|
||||||
@@ -94,6 +96,11 @@ spec:
|
|||||||
- secretRef:
|
- secretRef:
|
||||||
name: iot-postgres-secret
|
name: iot-postgres-secret
|
||||||
optional: true
|
optional: true
|
||||||
|
env:
|
||||||
|
# ADMIN_STATS_TOKEN — токен доступа к /iot-admin/stats (страница администратора).
|
||||||
|
# Менять на уникальный: kubectl set env deploy/sless-operator ADMIN_STATS_TOKEN=<token> -n sless
|
||||||
|
- name: ADMIN_STATS_TOKEN
|
||||||
|
value: "iot-admin-sless-2026"
|
||||||
readinessProbe:
|
readinessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /healthz
|
path: /healthz
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// Создано: 2026-04-06
|
||||||
|
// admin_embed.go — встраивает HTML страницы администратора IoT в бинарник через go:embed.
|
||||||
|
//
|
||||||
|
// Страница /iot-admin доступна без JWT — данные не содержит.
|
||||||
|
// Все данные загружаются через /iot-admin/stats (защищён ADMIN_STATS_TOKEN).
|
||||||
|
// Почему go:embed: единый деплой, нет отдельных pod-ов, нет nginx drift.
|
||||||
|
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// iotAdminHTML — бинарное содержимое страницы администратора IoT, встроенное при сборке.
|
||||||
|
//
|
||||||
|
//go:embed ui/iot-admin.html
|
||||||
|
var iotAdminHTML []byte
|
||||||
|
|
||||||
|
// ServeIoTAdmin обрабатывает GET /iot-admin — отдаёт HTML страницу администратора.
|
||||||
|
// Auth не нужен для HTML — сама страница ничего не содержит, только UI оболочка.
|
||||||
|
func ServeIoTAdmin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache, must-revalidate")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write(iotAdminHTML)
|
||||||
|
}
|
||||||
@@ -46,7 +46,10 @@ type Handler struct {
|
|||||||
PG *postgres.Store
|
PG *postgres.Store
|
||||||
// IoTPG — хранилище IoT телеметрии (per-tenant Postgres). nil если IOT_PG_DSN не задан.
|
// IoTPG — хранилище IoT телеметрии (per-tenant Postgres). nil если IOT_PG_DSN не задан.
|
||||||
IoTPG *iotpg.IoTPostgresStore
|
IoTPG *iotpg.IoTPostgresStore
|
||||||
Log *slog.Logger
|
// KafkaBrokers — адреса Kafka брокеров (KAFKA_BROKERS env var).
|
||||||
|
// Используется страницей администратора для чтения consumer lag.
|
||||||
|
KafkaBrokers string
|
||||||
|
Log *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeJSON отправляет JSON-ответ с указанным статусом.
|
// writeJSON отправляет JSON-ответ с указанным статусом.
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
// Создано: 2026-04-06
|
||||||
|
// iot_admin_stats_handler.go — handler для страницы администратора IoT.
|
||||||
|
//
|
||||||
|
// Endpoints:
|
||||||
|
// GET /iot-admin/stats — JSON с агрегированной статистикой (защищён ADMIN_STATS_TOKEN)
|
||||||
|
//
|
||||||
|
// Источники данных:
|
||||||
|
// - PostgreSQL (IoTPG): counts per tenant, last 1h/24h, latest rows
|
||||||
|
// - Kafka: consumer lag (latest offset - committed offset для group iot-pg-consumer)
|
||||||
|
// - K8s: статус подов iot-mqtt-bridge и iot-kafka-consumer
|
||||||
|
//
|
||||||
|
// Авторизация: Bearer из env ADMIN_STATS_TOKEN.
|
||||||
|
// Если ADMIN_STATS_TOKEN не задан — endpoint возвращает 503.
|
||||||
|
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
kafka "github.com/segmentio/kafka-go"
|
||||||
|
corev1 "k8s.io/api/core/v1"
|
||||||
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||||
|
)
|
||||||
|
|
||||||
|
// iotAdminPodStatus — краткая информация о k8s pod для страницы администратора.
|
||||||
|
type iotAdminPodStatus struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Phase string `json:"phase"`
|
||||||
|
Ready bool `json:"ready"`
|
||||||
|
Restarts int32 `json:"restarts"`
|
||||||
|
Age string `json:"age"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// iotAdminKafkaStats — информация о Kafka топике и consumer lag.
|
||||||
|
type iotAdminKafkaStats struct {
|
||||||
|
LatestOffset int64 `json:"latest_offset"`
|
||||||
|
CommittedOffset int64 `json:"committed_offset"`
|
||||||
|
ConsumerLag int64 `json:"consumer_lag"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminStats обрабатывает GET /iot-admin/stats.
|
||||||
|
// Проверяет Bearer-токен из ADMIN_STATS_TOKEN, затем собирает и возвращает статистику.
|
||||||
|
func (h *Handler) AdminStats(w http.ResponseWriter, r *http.Request) {
|
||||||
|
adminToken := os.Getenv("ADMIN_STATS_TOKEN")
|
||||||
|
if adminToken == "" {
|
||||||
|
writeJSON(w, http.StatusServiceUnavailable, errResp("admin stats not configured: ADMIN_STATS_TOKEN not set"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
authHeader := r.Header.Get("Authorization")
|
||||||
|
if !strings.HasPrefix(authHeader, "Bearer ") || strings.TrimPrefix(authHeader, "Bearer ") != adminToken {
|
||||||
|
writeJSON(w, http.StatusUnauthorized, errResp("unauthorized"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
result := map[string]any{
|
||||||
|
"collected_at": time.Now().UTC(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostgreSQL: статистика по всем tenant
|
||||||
|
if h.IoTPG != nil {
|
||||||
|
pgStats, err := h.IoTPG.GetAdminStats(ctx)
|
||||||
|
if err != nil {
|
||||||
|
result["postgres"] = map[string]any{"reachable": false, "error": err.Error()}
|
||||||
|
} else {
|
||||||
|
result["postgres"] = pgStats
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result["postgres"] = map[string]any{"reachable": false, "error": "IoTPG not configured"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kafka: consumer lag для топика iot.telemetry / группы iot-pg-consumer
|
||||||
|
result["kafka"] = h.collectIotKafkaLag(ctx)
|
||||||
|
|
||||||
|
// K8s: статус подов bridge и consumer
|
||||||
|
result["pods"] = h.collectIotPodStatuses(ctx)
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// collectIotKafkaLag получает latest offset топика и committed offset consumer group,
|
||||||
|
// вычисляет lag = latest - committed.
|
||||||
|
// Topic: "iot.telemetry", Consumer Group: "iot-pg-consumer".
|
||||||
|
func (h *Handler) collectIotKafkaLag(ctx context.Context) iotAdminKafkaStats {
|
||||||
|
if h.KafkaBrokers == "" {
|
||||||
|
return iotAdminKafkaStats{Error: "KAFKA_BROKERS not configured"}
|
||||||
|
}
|
||||||
|
|
||||||
|
brokers := strings.Split(h.KafkaBrokers, ",")
|
||||||
|
brokerAddr := kafka.TCP(brokers...)
|
||||||
|
|
||||||
|
kc := &kafka.Client{
|
||||||
|
Addr: brokerAddr,
|
||||||
|
Timeout: 5 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
const topic = "iot.telemetry"
|
||||||
|
const group = "iot-pg-consumer"
|
||||||
|
|
||||||
|
// Получаем latest offset (конец лога — сколько всего сообщений прошло)
|
||||||
|
offsetsResp, err := kc.ListOffsets(ctx, &kafka.ListOffsetsRequest{
|
||||||
|
Addr: brokerAddr,
|
||||||
|
Topics: map[string][]kafka.OffsetRequest{
|
||||||
|
topic: {kafka.LastOffsetOf(0)},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return iotAdminKafkaStats{Error: fmt.Sprintf("list offsets: %v", err)}
|
||||||
|
}
|
||||||
|
|
||||||
|
var latestOffset int64
|
||||||
|
if partitions, ok := offsetsResp.Topics[topic]; ok && len(partitions) > 0 {
|
||||||
|
if partitions[0].Error == nil {
|
||||||
|
latestOffset = partitions[0].LastOffset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем committed offset consumer group (что consumer уже обработал)
|
||||||
|
fetchResp, err := kc.OffsetFetch(ctx, &kafka.OffsetFetchRequest{
|
||||||
|
Addr: brokerAddr,
|
||||||
|
GroupID: group,
|
||||||
|
Topics: map[string][]int{topic: {0}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return iotAdminKafkaStats{
|
||||||
|
LatestOffset: latestOffset,
|
||||||
|
Error: fmt.Sprintf("offset fetch: %v", err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var committedOffset int64
|
||||||
|
if partitions, ok := fetchResp.Topics[topic]; ok && len(partitions) > 0 {
|
||||||
|
if partitions[0].Error == nil {
|
||||||
|
committedOffset = partitions[0].CommittedOffset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lag := latestOffset - committedOffset
|
||||||
|
if lag < 0 {
|
||||||
|
lag = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return iotAdminKafkaStats{
|
||||||
|
LatestOffset: latestOffset,
|
||||||
|
CommittedOffset: committedOffset,
|
||||||
|
ConsumerLag: lag,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// collectIotPodStatuses собирает статус k8s pods для bridge и consumer по label app={name}.
|
||||||
|
func (h *Handler) collectIotPodStatuses(ctx context.Context) map[string]any {
|
||||||
|
result := map[string]any{}
|
||||||
|
|
||||||
|
for _, appLabel := range []string{"iot-mqtt-bridge", "iot-kafka-consumer"} {
|
||||||
|
podList := &corev1.PodList{}
|
||||||
|
if err := h.K8s.List(ctx, podList,
|
||||||
|
client.InNamespace("sless"),
|
||||||
|
client.MatchingLabels{"app": appLabel},
|
||||||
|
); err != nil {
|
||||||
|
result[appLabel] = map[string]any{"error": err.Error()}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(podList.Items) == 0 {
|
||||||
|
result[appLabel] = map[string]any{"status": "not found"}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
pod := podList.Items[0]
|
||||||
|
var restarts int32
|
||||||
|
for _, cs := range pod.Status.ContainerStatuses {
|
||||||
|
restarts += cs.RestartCount
|
||||||
|
}
|
||||||
|
ready := false
|
||||||
|
for _, cond := range pod.Status.Conditions {
|
||||||
|
if cond.Type == corev1.PodReady && cond.Status == corev1.ConditionTrue {
|
||||||
|
ready = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result[appLabel] = iotAdminPodStatus{
|
||||||
|
Name: pod.Name,
|
||||||
|
Phase: string(pod.Status.Phase),
|
||||||
|
Ready: ready,
|
||||||
|
Restarts: restarts,
|
||||||
|
Age: iotFormatAge(pod.CreationTimestamp.Time),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// iotFormatAge возвращает человекочитаемый возраст (s/m/h/d) pod-а.
|
||||||
|
func iotFormatAge(created time.Time) string {
|
||||||
|
d := time.Since(created)
|
||||||
|
switch {
|
||||||
|
case d < time.Minute:
|
||||||
|
return fmt.Sprintf("%ds", int(d.Seconds()))
|
||||||
|
case d < time.Hour:
|
||||||
|
return fmt.Sprintf("%dm", int(d.Minutes()))
|
||||||
|
case d < 24*time.Hour:
|
||||||
|
return fmt.Sprintf("%dh", int(d.Hours()))
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%dd", int(d.Hours()/24))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,6 +44,11 @@ func NewRouter(h *handler.Handler, log *slog.Logger) http.Handler {
|
|||||||
// IoT Консоль — статический HTML, публично доступен
|
// IoT Консоль — статический HTML, публично доступен
|
||||||
r.HandleFunc("/console", ServeIoTConsole).Methods(http.MethodGet)
|
r.HandleFunc("/console", ServeIoTConsole).Methods(http.MethodGet)
|
||||||
|
|
||||||
|
// IoT Admin — страница администратора (HTML без auth + JSON API с ADMIN_STATS_TOKEN)
|
||||||
|
// Не для конечных пользователей: показывает Kafka lag, pod statuses, PG stats per tenant.
|
||||||
|
r.HandleFunc("/iot-admin", ServeIoTAdmin).Methods(http.MethodGet)
|
||||||
|
r.HandleFunc("/iot-admin/stats", h.AdminStats).Methods(http.MethodGet)
|
||||||
|
|
||||||
// Публичный прокси для вызова HTTP-триггеров — без auth токена
|
// Публичный прокси для вызова HTTP-триггеров — без auth токена
|
||||||
// Все HTTP методы разрешены (GET/POST/PUT/... — решает сама функция)
|
// Все HTTP методы разрешены (GET/POST/PUT/... — решает сама функция)
|
||||||
r.PathPrefix("/fn/{namespace}/{name}").HandlerFunc(h.InvokeFunction)
|
r.PathPrefix("/fn/{namespace}/{name}").HandlerFunc(h.InvokeFunction)
|
||||||
|
|||||||
@@ -0,0 +1,547 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<!-- Создано: 2026-04-06
|
||||||
|
iot-admin.html — страница администратора IoT pipeline.
|
||||||
|
Показывает: PostgreSQL stats per tenant, Kafka consumer lag, K8s pod statuses.
|
||||||
|
Auth: ADMIN_STATS_TOKEN вводится вручную и хранится в sessionStorage.
|
||||||
|
Раздаётся по GET /iot-admin (go:embed в бинарнике оператора).
|
||||||
|
НЕ для конечных пользователей — только для администратора платформы. -->
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Nubes IoT Admin</title>
|
||||||
|
<link rel="icon" href="https://terra.k8c.ru/docs/nubes/nubes/2.0.2/30_registry/assets/favicon.png">
|
||||||
|
<style>
|
||||||
|
/* Nubes brand palette — те же цвета что в iot-console.html */
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||||
|
background: #001120; color: #e2ecf6; min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Navbar */
|
||||||
|
.navbar {
|
||||||
|
background: #001C34; border-bottom: 1px solid #0b2d50;
|
||||||
|
padding: 0 28px; height: 58px;
|
||||||
|
display: flex; align-items: center; gap: 14px;
|
||||||
|
}
|
||||||
|
.navbar-logo { display: flex; align-items: center; gap: 10px; text-decoration: none; }
|
||||||
|
.navbar-logo img { height: 18px; filter: brightness(0) invert(1); }
|
||||||
|
.navbar-logo-sep { width: 1px; height: 18px; background: #1a4a73; margin: 0 4px; }
|
||||||
|
.navbar-title { font-size: 15px; font-weight: 600; color: #e2ecf6; }
|
||||||
|
.navbar-badge {
|
||||||
|
background: #2d1a00; border: 1px solid #7a3a00; color: #f0a030;
|
||||||
|
font-size: 10px; font-weight: 700; padding: 2px 7px; border-radius: 4px;
|
||||||
|
letter-spacing: 0.5px; text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.navbar-spacer { flex: 1; }
|
||||||
|
.navbar-refresh {
|
||||||
|
background: #0f3a60; border: 1px solid #1a5a8a; color: #7fc8f8;
|
||||||
|
padding: 6px 14px; border-radius: 6px; font-size: 13px; cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.navbar-refresh:hover { background: #1a5080; }
|
||||||
|
.navbar-refresh:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* Layout */
|
||||||
|
.container { max-width: 1280px; margin: 0 auto; padding: 28px 24px; }
|
||||||
|
|
||||||
|
/* Auth box */
|
||||||
|
.auth-box {
|
||||||
|
background: #001929; border: 1px solid #0b2d50; border-radius: 12px;
|
||||||
|
padding: 40px; max-width: 480px; margin: 80px auto;
|
||||||
|
display: flex; flex-direction: column; gap: 16px;
|
||||||
|
}
|
||||||
|
.auth-box h2 { font-size: 20px; font-weight: 600; color: #7fc8f8; }
|
||||||
|
.auth-box p { font-size: 13px; color: #6b8eaa; }
|
||||||
|
.auth-input {
|
||||||
|
background: #001120; border: 1px solid #1a4a73; color: #e2ecf6;
|
||||||
|
padding: 10px 14px; border-radius: 8px; font-size: 14px; font-family: monospace;
|
||||||
|
width: 100%; outline: none;
|
||||||
|
}
|
||||||
|
.auth-input:focus { border-color: #1a7fd4; }
|
||||||
|
.auth-btn {
|
||||||
|
background: #1a7fd4; border: none; color: #fff;
|
||||||
|
padding: 10px 20px; border-radius: 8px; font-size: 14px; cursor: pointer;
|
||||||
|
font-weight: 600; transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.auth-btn:hover { background: #1a6ab8; }
|
||||||
|
.auth-error { color: #f87171; font-size: 13px; }
|
||||||
|
|
||||||
|
/* Section header */
|
||||||
|
.section-header {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
margin-bottom: 16px; padding-bottom: 10px;
|
||||||
|
border-bottom: 1px solid #0b2d50;
|
||||||
|
}
|
||||||
|
.section-icon { width: 20px; height: 20px; opacity: 0.7; }
|
||||||
|
.section-title { font-size: 16px; font-weight: 600; color: #a0c4e8; }
|
||||||
|
.section { margin-bottom: 32px; }
|
||||||
|
|
||||||
|
/* Cards grid */
|
||||||
|
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; }
|
||||||
|
|
||||||
|
/* Stat card */
|
||||||
|
.card {
|
||||||
|
background: #001929; border: 1px solid #0b2d50; border-radius: 10px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.card-title { font-size: 12px; color: #6b8eaa; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px; }
|
||||||
|
.card-value { font-size: 28px; font-weight: 700; color: #e2ecf6; }
|
||||||
|
.card-sub { font-size: 12px; color: #6b8eaa; margin-top: 4px; }
|
||||||
|
.card-accent { color: #1a7fd4; }
|
||||||
|
.card-warn { color: #f59e0b; }
|
||||||
|
.card-ok { color: #34d399; }
|
||||||
|
.card-err { color: #f87171; }
|
||||||
|
|
||||||
|
/* Pod status card */
|
||||||
|
.pod-card {
|
||||||
|
background: #001929; border: 1px solid #0b2d50; border-radius: 10px;
|
||||||
|
padding: 20px; display: flex; flex-direction: column; gap: 8px;
|
||||||
|
}
|
||||||
|
.pod-name { font-size: 13px; font-weight: 600; color: #7fc8f8; font-family: monospace; }
|
||||||
|
.pod-row { display: flex; justify-content: space-between; font-size: 12px; }
|
||||||
|
.pod-label { color: #6b8eaa; }
|
||||||
|
.pod-val { color: #e2ecf6; }
|
||||||
|
.badge {
|
||||||
|
display: inline-block; padding: 2px 8px; border-radius: 4px;
|
||||||
|
font-size: 11px; font-weight: 700;
|
||||||
|
}
|
||||||
|
.badge-ok { background: #052e16; color: #34d399; border: 1px solid #064e3b; }
|
||||||
|
.badge-warn { background: #2d1c00; color: #f59e0b; border: 1px solid #4d3000; }
|
||||||
|
.badge-err { background: #300; color: #f87171; border: 1px solid #500; }
|
||||||
|
|
||||||
|
/* Tenant table */
|
||||||
|
.tenant-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||||
|
.tenant-table th {
|
||||||
|
text-align: left; padding: 8px 12px; color: #6b8eaa;
|
||||||
|
border-bottom: 1px solid #0b2d50; font-weight: 600; font-size: 11px;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
.tenant-table td { padding: 10px 12px; border-bottom: 1px solid #071a28; vertical-align: top; }
|
||||||
|
.tenant-table tr:last-child td { border-bottom: none; }
|
||||||
|
.tenant-table tr:hover td { background: rgba(26,127,212,0.05); }
|
||||||
|
.ns-tag {
|
||||||
|
font-family: monospace; font-size: 12px; color: #7fc8f8;
|
||||||
|
background: #0b2d50; padding: 2px 6px; border-radius: 4px;
|
||||||
|
}
|
||||||
|
.num-big { font-size: 16px; font-weight: 600; color: #e2ecf6; }
|
||||||
|
.num-small { font-size: 12px; color: #6b8eaa; }
|
||||||
|
|
||||||
|
/* Latest msgs mini list */
|
||||||
|
.latest-list { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.latest-item {
|
||||||
|
background: #001120; border: 1px solid #0b2d50; border-radius: 6px;
|
||||||
|
padding: 6px 10px; font-size: 11px;
|
||||||
|
}
|
||||||
|
.latest-dev { color: #7fc8f8; font-weight: 600; }
|
||||||
|
.latest-ts { color: #6b8eaa; margin-left: 6px; }
|
||||||
|
.latest-payload { color: #a0c4e8; margin-top: 2px; word-break: break-all; font-family: monospace; }
|
||||||
|
|
||||||
|
/* Last updated */
|
||||||
|
.last-updated { font-size: 12px; color: #2d5070; text-align: center; margin-top: 16px; }
|
||||||
|
|
||||||
|
/* Status dot */
|
||||||
|
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; }
|
||||||
|
.dot-ok { background: #34d399; }
|
||||||
|
.dot-warn { background: #f59e0b; }
|
||||||
|
.dot-err { background: #f87171; }
|
||||||
|
|
||||||
|
/* Kafka lag bar */
|
||||||
|
.lag-bar-wrap { background: #001120; border-radius: 4px; height: 6px; margin-top: 8px; overflow: hidden; }
|
||||||
|
.lag-bar { height: 100%; border-radius: 4px; transition: width 0.5s; min-width: 2px; }
|
||||||
|
.lag-bar-ok { background: #34d399; }
|
||||||
|
.lag-bar-warn { background: #f59e0b; }
|
||||||
|
|
||||||
|
/* Spinner */
|
||||||
|
.spinner {
|
||||||
|
border: 3px solid #0b2d50; border-top-color: #1a7fd4;
|
||||||
|
border-radius: 50%; width: 32px; height: 32px;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
margin: 60px auto;
|
||||||
|
}
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
/* Error banner */
|
||||||
|
.error-banner {
|
||||||
|
background: #1a0000; border: 1px solid #5a0000; color: #f87171;
|
||||||
|
padding: 12px 16px; border-radius: 8px; font-size: 13px; margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Auto-refresh indicator */
|
||||||
|
.refresh-timer {
|
||||||
|
font-size: 11px; color: #2d5070; display: flex; align-items: center; gap: 6px;
|
||||||
|
}
|
||||||
|
.refresh-progress {
|
||||||
|
width: 60px; height: 2px; background: #0b2d50; border-radius: 2px; overflow: hidden;
|
||||||
|
}
|
||||||
|
.refresh-bar {
|
||||||
|
height: 100%; background: #1a7fd4; border-radius: 2px;
|
||||||
|
transition: width 1s linear;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- Navbar -->
|
||||||
|
<nav class="navbar">
|
||||||
|
<a class="navbar-logo" href="#" aria-label="Nubes">
|
||||||
|
<img src="https://terra.k8c.ru/docs/nubes/nubes/2.0.2/30_registry/assets/logo.svg" alt="Nubes">
|
||||||
|
</a>
|
||||||
|
<div class="navbar-logo-sep"></div>
|
||||||
|
<span class="navbar-title">IoT Admin</span>
|
||||||
|
<span class="navbar-badge">Admin Only</span>
|
||||||
|
<div class="navbar-spacer"></div>
|
||||||
|
<div class="refresh-timer" id="refreshTimer" style="display:none">
|
||||||
|
<span id="refreshCountdown">30</span>s
|
||||||
|
<div class="refresh-progress"><div class="refresh-bar" id="refreshBar" style="width:100%"></div></div>
|
||||||
|
</div>
|
||||||
|
<button class="navbar-refresh" id="btnRefresh" onclick="loadStats()" disabled>Обновить</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Main content -->
|
||||||
|
<div class="container">
|
||||||
|
<!-- Auth box (показывается до ввода токена) -->
|
||||||
|
<div class="auth-box" id="authBox">
|
||||||
|
<h2>Доступ для администратора</h2>
|
||||||
|
<p>Введите ADMIN_STATS_TOKEN для просмотра статистики IoT pipeline.</p>
|
||||||
|
<input class="auth-input" id="tokenInput" type="password"
|
||||||
|
placeholder="Bearer token..." autocomplete="off"
|
||||||
|
onkeydown="if(event.key==='Enter') doAuth()">
|
||||||
|
<button class="auth-btn" onclick="doAuth()">Войти</button>
|
||||||
|
<div class="auth-error" id="authError" style="display:none"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Контент (показывается после авторизации) -->
|
||||||
|
<div id="mainContent" style="display:none">
|
||||||
|
<div class="error-banner" id="errorBanner" style="display:none"></div>
|
||||||
|
|
||||||
|
<!-- Spinner при загрузке -->
|
||||||
|
<div class="spinner" id="spinner"></div>
|
||||||
|
|
||||||
|
<!-- Данные -->
|
||||||
|
<div id="dataContent" style="display:none">
|
||||||
|
|
||||||
|
<!-- Kafka -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-header">
|
||||||
|
<svg class="section-icon" viewBox="0 0 24 24" fill="none" stroke="#7fc8f8" stroke-width="2">
|
||||||
|
<path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/>
|
||||||
|
</svg>
|
||||||
|
<span class="section-title">Kafka</span>
|
||||||
|
</div>
|
||||||
|
<div class="cards" id="kafkaCards"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- K8s Pods -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-header">
|
||||||
|
<svg class="section-icon" viewBox="0 0 24 24" fill="none" stroke="#7fc8f8" stroke-width="2">
|
||||||
|
<rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/>
|
||||||
|
</svg>
|
||||||
|
<span class="section-title">Pods</span>
|
||||||
|
</div>
|
||||||
|
<div class="cards" id="podCards"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- PostgreSQL per tenant -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-header">
|
||||||
|
<svg class="section-icon" viewBox="0 0 24 24" fill="none" stroke="#7fc8f8" stroke-width="2">
|
||||||
|
<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v14c0 1.66 4.03 3 9 3s9-1.34 9-3V5"/>
|
||||||
|
<path d="M3 12c0 1.66 4.03 3 9 3s9-1.34 9-3"/>
|
||||||
|
</svg>
|
||||||
|
<span class="section-title">PostgreSQL — Telemetry</span>
|
||||||
|
</div>
|
||||||
|
<div class="cards" style="margin-bottom:16px" id="pgSummaryCards"></div>
|
||||||
|
<div style="background:#001929;border:1px solid #0b2d50;border-radius:10px;overflow:auto">
|
||||||
|
<table class="tenant-table" id="tenantTable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Namespace</th>
|
||||||
|
<th>Total</th>
|
||||||
|
<th>Last 1h</th>
|
||||||
|
<th>Last 24h</th>
|
||||||
|
<th>Последние сообщения</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="tenantTableBody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="last-updated" id="lastUpdated"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ── State ──────────────────────────────────────────────────────────────────
|
||||||
|
const API_BASE = window.location.origin;
|
||||||
|
let adminToken = sessionStorage.getItem('iot_admin_token') || '';
|
||||||
|
let refreshInterval = null;
|
||||||
|
let refreshCountdown = 30;
|
||||||
|
|
||||||
|
// ── Auth ───────────────────────────────────────────────────────────────────
|
||||||
|
function doAuth() {
|
||||||
|
const input = document.getElementById('tokenInput').value.trim();
|
||||||
|
if (!input) return;
|
||||||
|
adminToken = input;
|
||||||
|
sessionStorage.setItem('iot_admin_token', adminToken);
|
||||||
|
document.getElementById('authBox').style.display = 'none';
|
||||||
|
document.getElementById('mainContent').style.display = 'block';
|
||||||
|
loadStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showAuthError(msg) {
|
||||||
|
const el = document.getElementById('authError');
|
||||||
|
el.textContent = msg;
|
||||||
|
el.style.display = 'block';
|
||||||
|
// Сбрасываем токен — он не подошёл
|
||||||
|
adminToken = '';
|
||||||
|
sessionStorage.removeItem('iot_admin_token');
|
||||||
|
document.getElementById('authBox').style.display = 'block';
|
||||||
|
document.getElementById('mainContent').style.display = 'none';
|
||||||
|
if (refreshInterval) { clearInterval(refreshInterval); refreshInterval = null; }
|
||||||
|
document.getElementById('refreshTimer').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если токен уже в sessionStorage — пропускаем auth box
|
||||||
|
if (adminToken) {
|
||||||
|
document.getElementById('authBox').style.display = 'none';
|
||||||
|
document.getElementById('mainContent').style.display = 'block';
|
||||||
|
document.getElementById('spinner').style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Load stats ─────────────────────────────────────────────────────────────
|
||||||
|
async function loadStats() {
|
||||||
|
if (!adminToken) return;
|
||||||
|
document.getElementById('btnRefresh').disabled = true;
|
||||||
|
document.getElementById('spinner').style.display = 'block';
|
||||||
|
document.getElementById('dataContent').style.display = 'none';
|
||||||
|
document.getElementById('errorBanner').style.display = 'none';
|
||||||
|
|
||||||
|
resetRefreshTimer();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${API_BASE}/iot-admin/stats`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${adminToken}` }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (resp.status === 401 || resp.status === 503) {
|
||||||
|
const body = await resp.json().catch(() => ({}));
|
||||||
|
showAuthError(body.error || 'Ошибка авторизации');
|
||||||
|
document.getElementById('spinner').style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
throw new Error(`HTTP ${resp.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await resp.json();
|
||||||
|
renderAll(data);
|
||||||
|
document.getElementById('spinner').style.display = 'none';
|
||||||
|
document.getElementById('dataContent').style.display = 'block';
|
||||||
|
document.getElementById('refreshTimer').style.display = 'flex';
|
||||||
|
document.getElementById('lastUpdated').textContent =
|
||||||
|
'Обновлено: ' + new Date(data.collected_at).toLocaleTimeString('ru-RU');
|
||||||
|
setupAutoRefresh();
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('spinner').style.display = 'none';
|
||||||
|
showBanner('Ошибка загрузки данных: ' + e.message);
|
||||||
|
document.getElementById('dataContent').style.display = 'block';
|
||||||
|
} finally {
|
||||||
|
document.getElementById('btnRefresh').disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showBanner(msg) {
|
||||||
|
const el = document.getElementById('errorBanner');
|
||||||
|
el.textContent = msg;
|
||||||
|
el.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Auto-refresh ───────────────────────────────────────────────────────────
|
||||||
|
function setupAutoRefresh() {
|
||||||
|
if (refreshInterval) return; // уже запущен
|
||||||
|
refreshInterval = setInterval(() => {
|
||||||
|
refreshCountdown--;
|
||||||
|
document.getElementById('refreshCountdown').textContent = refreshCountdown;
|
||||||
|
const pct = (refreshCountdown / 30) * 100;
|
||||||
|
document.getElementById('refreshBar').style.width = pct + '%';
|
||||||
|
if (refreshCountdown <= 0) {
|
||||||
|
clearInterval(refreshInterval);
|
||||||
|
refreshInterval = null;
|
||||||
|
loadStats();
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetRefreshTimer() {
|
||||||
|
if (refreshInterval) { clearInterval(refreshInterval); refreshInterval = null; }
|
||||||
|
refreshCountdown = 30;
|
||||||
|
document.getElementById('refreshCountdown').textContent = '30';
|
||||||
|
document.getElementById('refreshBar').style.width = '100%';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render ─────────────────────────────────────────────────────────────────
|
||||||
|
function renderAll(data) {
|
||||||
|
renderKafka(data.kafka || {});
|
||||||
|
renderPods(data.pods || {});
|
||||||
|
renderPostgres(data.postgres || {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kafka section
|
||||||
|
function renderKafka(kafka) {
|
||||||
|
const el = document.getElementById('kafkaCards');
|
||||||
|
if (kafka.error) {
|
||||||
|
el.innerHTML = `<div class="card"><div class="card-title">Ошибка</div>
|
||||||
|
<div class="card-value card-err" style="font-size:14px">${esc(kafka.error)}</div></div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lag = kafka.consumer_lag || 0;
|
||||||
|
const latest = kafka.latest_offset || 0;
|
||||||
|
const committed = kafka.committed_offset || 0;
|
||||||
|
const lagClass = lag === 0 ? 'card-ok' : lag < 100 ? 'card-warn' : 'card-err';
|
||||||
|
const barClass = lag === 0 ? 'lag-bar-ok' : 'lag-bar-warn';
|
||||||
|
const barWidth = latest > 0 ? Math.max(2, Math.round((committed / latest) * 100)) : 100;
|
||||||
|
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">Consumer Lag</div>
|
||||||
|
<div class="card-value ${lagClass}">${lag}</div>
|
||||||
|
<div class="card-sub">iot-pg-consumer / iot.telemetry</div>
|
||||||
|
<div class="lag-bar-wrap"><div class="lag-bar ${barClass}" style="width:${barWidth}%"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">Latest Offset (всего прошло)</div>
|
||||||
|
<div class="card-value card-accent">${latest.toLocaleString()}</div>
|
||||||
|
<div class="card-sub">Kafka log end offset</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">Committed Offset</div>
|
||||||
|
<div class="card-value">${committed.toLocaleString()}</div>
|
||||||
|
<div class="card-sub">Consumer обработал</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pods section
|
||||||
|
function renderPods(pods) {
|
||||||
|
const el = document.getElementById('podCards');
|
||||||
|
el.innerHTML = '';
|
||||||
|
|
||||||
|
const labels = {
|
||||||
|
'iot-mqtt-bridge': 'MQTT Bridge',
|
||||||
|
'iot-kafka-consumer': 'Kafka Consumer'
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [key, pod] of Object.entries(pods)) {
|
||||||
|
const title = labels[key] || key;
|
||||||
|
if (pod.error || pod.status === 'not found') {
|
||||||
|
el.innerHTML += `
|
||||||
|
<div class="pod-card">
|
||||||
|
<div class="pod-name">${esc(title)}</div>
|
||||||
|
<div style="color:#f87171;font-size:12px">${esc(pod.error || 'Pod not found')}</div>
|
||||||
|
</div>`;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const readyBadge = pod.ready
|
||||||
|
? '<span class="badge badge-ok">Ready</span>'
|
||||||
|
: '<span class="badge badge-warn">Not Ready</span>';
|
||||||
|
const restartColor = pod.restarts > 5 ? 'card-err' : pod.restarts > 0 ? 'card-warn' : 'card-ok';
|
||||||
|
|
||||||
|
el.innerHTML += `
|
||||||
|
<div class="pod-card">
|
||||||
|
<div class="pod-name">
|
||||||
|
<span class="dot dot-${pod.ready ? 'ok' : 'warn'}"></span>${esc(title)}
|
||||||
|
</div>
|
||||||
|
<div class="pod-row"><span class="pod-label">Pod</span><span class="pod-val" style="font-family:monospace;font-size:11px">${esc(pod.name)}</span></div>
|
||||||
|
<div class="pod-row"><span class="pod-label">Phase</span><span class="pod-val">${esc(pod.phase)} ${readyBadge}</span></div>
|
||||||
|
<div class="pod-row"><span class="pod-label">Restarts</span><span class="pod-val ${restartColor}">${pod.restarts}</span></div>
|
||||||
|
<div class="pod-row"><span class="pod-label">Age</span><span class="pod-val">${esc(pod.age)}</span></div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Postgres section
|
||||||
|
function renderPostgres(pg) {
|
||||||
|
const summaryEl = document.getElementById('pgSummaryCards');
|
||||||
|
const tbodyEl = document.getElementById('tenantTableBody');
|
||||||
|
|
||||||
|
if (!pg.reachable) {
|
||||||
|
summaryEl.innerHTML = `<div class="card"><div class="card-title">Ошибка</div>
|
||||||
|
<div class="card-value card-err" style="font-size:14px">${esc(pg.error || 'Unreachable')}</div></div>`;
|
||||||
|
tbodyEl.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tenants = pg.tenants || [];
|
||||||
|
const totalAll = pg.total_all || 0;
|
||||||
|
|
||||||
|
summaryEl.innerHTML = `
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">Всего записей</div>
|
||||||
|
<div class="card-value card-accent">${totalAll.toLocaleString()}</div>
|
||||||
|
<div class="card-sub">Все tenant, iot_telemetry</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">Tenant-ов</div>
|
||||||
|
<div class="card-value">${tenants.length}</div>
|
||||||
|
<div class="card-sub">Активных namespace</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
tbodyEl.innerHTML = tenants.map(t => {
|
||||||
|
if (t.error) {
|
||||||
|
return `<tr><td><span class="ns-tag">${esc(t.namespace)}</span></td>
|
||||||
|
<td colspan="4" style="color:#f87171">${esc(t.error)}</td></tr>`;
|
||||||
|
}
|
||||||
|
const latest = (t.latest || []).slice(0, 3);
|
||||||
|
const latestHtml = latest.length === 0
|
||||||
|
? '<span style="color:#2d5070">нет данных</span>'
|
||||||
|
: `<div class="latest-list">${latest.map(row => `
|
||||||
|
<div class="latest-item">
|
||||||
|
<span class="latest-dev">${esc(row.device_id)}</span>
|
||||||
|
<span class="latest-ts">${formatTs(row.ts)}</span>
|
||||||
|
<div class="latest-payload">${esc(truncate(JSON.stringify(row.payload), 80))}</div>
|
||||||
|
</div>`).join('')}</div>`;
|
||||||
|
|
||||||
|
return `<tr>
|
||||||
|
<td><span class="ns-tag">${esc(t.namespace)}</span><br>
|
||||||
|
<span style="font-size:11px;color:#2d5070">${esc(t.db_name)}</span></td>
|
||||||
|
<td><span class="num-big">${(t.total||0).toLocaleString()}</span></td>
|
||||||
|
<td><span class="num-small">${(t.last_1h||0).toLocaleString()}</span></td>
|
||||||
|
<td><span class="num-small">${(t.last_24h||0).toLocaleString()}</span></td>
|
||||||
|
<td>${latestHtml}</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Utils ──────────────────────────────────────────────────────────────────
|
||||||
|
function esc(str) {
|
||||||
|
if (str == null) return '';
|
||||||
|
return String(str).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(str, len) {
|
||||||
|
if (!str) return '';
|
||||||
|
return str.length > len ? str.slice(0, len) + '…' : str;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTs(ts) {
|
||||||
|
if (!ts) return '';
|
||||||
|
try {
|
||||||
|
const d = new Date(ts);
|
||||||
|
return d.toLocaleTimeString('ru-RU', {hour:'2-digit', minute:'2-digit', second:'2-digit'});
|
||||||
|
} catch { return ts; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Init ───────────────────────────────────────────────────────────────────
|
||||||
|
if (adminToken) {
|
||||||
|
loadStats();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -225,6 +225,99 @@ func (s *IoTPostgresStore) QueryTelemetry(ctx context.Context, namespace, device
|
|||||||
return result, rows.Err()
|
return result, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TenantPGStats — статистика телеметрии одного tenant за разные периоды.
|
||||||
|
type TenantPGStats struct {
|
||||||
|
Namespace string `json:"namespace"`
|
||||||
|
DBName string `json:"db_name"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
Last1h int64 `json:"last_1h"`
|
||||||
|
Last24h int64 `json:"last_24h"`
|
||||||
|
Latest []TelemetryRow `json:"latest"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostgresAdminStats — агрегированная статистика по всем tenant для страницы администратора.
|
||||||
|
type PostgresAdminStats struct {
|
||||||
|
Tenants []TenantPGStats `json:"tenants"`
|
||||||
|
TotalAll int64 `json:"total_all"`
|
||||||
|
Reachable bool `json:"reachable"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAdminStats собирает статистику по всем tenant из management DB.
|
||||||
|
// Используется только страницей администратора — не для tenant API.
|
||||||
|
func (s *IoTPostgresStore) GetAdminStats(ctx context.Context) (*PostgresAdminStats, error) {
|
||||||
|
// Список всех тенантов из management DB
|
||||||
|
nsRows, err := s.adminDB.QueryContext(ctx, `SELECT namespace FROM tenant_credentials ORDER BY namespace`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("iotpg: list tenants: %w", err)
|
||||||
|
}
|
||||||
|
defer nsRows.Close()
|
||||||
|
|
||||||
|
var namespaces []string
|
||||||
|
for nsRows.Next() {
|
||||||
|
var ns string
|
||||||
|
if err := nsRows.Scan(&ns); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
namespaces = append(namespaces, ns)
|
||||||
|
}
|
||||||
|
if err := nsRows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &PostgresAdminStats{
|
||||||
|
Reachable: true,
|
||||||
|
Tenants: make([]TenantPGStats, 0, len(namespaces)),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ns := range namespaces {
|
||||||
|
stats := TenantPGStats{
|
||||||
|
Namespace: ns,
|
||||||
|
DBName: tenantDBName(ns),
|
||||||
|
}
|
||||||
|
|
||||||
|
tenantDB, err := s.getTenantDB(ctx, ns)
|
||||||
|
if err != nil {
|
||||||
|
stats.Error = err.Error()
|
||||||
|
result.Tenants = append(result.Tenants, stats)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Counts: total, last 1h, last 24h — одним запросом
|
||||||
|
err = tenantDB.QueryRowContext(ctx, `
|
||||||
|
SELECT
|
||||||
|
COUNT(*),
|
||||||
|
COUNT(*) FILTER (WHERE ts > NOW() - INTERVAL '1 hour'),
|
||||||
|
COUNT(*) FILTER (WHERE ts > NOW() - INTERVAL '24 hours')
|
||||||
|
FROM iot_telemetry`).Scan(&stats.Total, &stats.Last1h, &stats.Last24h)
|
||||||
|
if err != nil {
|
||||||
|
stats.Error = err.Error()
|
||||||
|
result.Tenants = append(result.Tenants, stats)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result.TotalAll += stats.Total
|
||||||
|
|
||||||
|
// Последние 5 сообщений для предпросмотра
|
||||||
|
latestRows, err := tenantDB.QueryContext(ctx,
|
||||||
|
`SELECT id, device_id, ts, payload FROM iot_telemetry ORDER BY ts DESC LIMIT 5`)
|
||||||
|
if err == nil {
|
||||||
|
defer latestRows.Close()
|
||||||
|
for latestRows.Next() {
|
||||||
|
var r TelemetryRow
|
||||||
|
var rawPayload []byte
|
||||||
|
if err := latestRows.Scan(&r.ID, &r.DeviceID, &r.Ts, &rawPayload); err == nil {
|
||||||
|
r.Payload = json.RawMessage(rawPayload)
|
||||||
|
stats.Latest = append(stats.Latest, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Tenants = append(result.Tenants, stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
// isDBNotExistErr проверяет что ошибка — «database does not exist» (PostgreSQL code 3D000).
|
// isDBNotExistErr проверяет что ошибка — «database does not exist» (PostgreSQL code 3D000).
|
||||||
// Используется в QueryTelemetry: если DB нет — просто нет данных, не ошибка системы.
|
// Используется в QueryTelemetry: если DB нет — просто нет данных, не ошибка системы.
|
||||||
func isDBNotExistErr(err error) bool {
|
func isDBNotExistErr(err error) bool {
|
||||||
|
|||||||
@@ -227,12 +227,13 @@ func main() {
|
|||||||
|
|
||||||
// REST API сервер — запускается параллельно с operator manager
|
// REST API сервер — запускается параллельно с operator manager
|
||||||
apiHandler := slessapi.NewRouter(&handler.Handler{
|
apiHandler := slessapi.NewRouter(&handler.Handler{
|
||||||
K8s: mgr.GetClient(),
|
K8s: mgr.GetClient(),
|
||||||
Scheme: mgr.GetScheme(),
|
Scheme: mgr.GetScheme(),
|
||||||
S3: s3Client,
|
S3: s3Client,
|
||||||
PG: pg,
|
PG: pg,
|
||||||
IoTPG: iotPGStore,
|
IoTPG: iotPGStore,
|
||||||
Log: log,
|
KafkaBrokers: os.Getenv("KAFKA_BROKERS"),
|
||||||
|
Log: log,
|
||||||
}, log)
|
}, log)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
|
|||||||
Reference in New Issue
Block a user