diff --git a/deployments/k8s/emqx-ws-ingress.yaml b/deployments/k8s/emqx-ws-ingress.yaml index df382fe..b360cb9 100644 --- a/deployments/k8s/emqx-ws-ingress.yaml +++ b/deployments/k8s/emqx-ws-ingress.yaml @@ -1,9 +1,11 @@ -# Создано: 2026-04-04 +# Изменено: 2026-04-04 (iot-console: добавлен path /console → sless-operator:9090) # ВРЕМЕННЫЙ WORKAROUND: MQTT over WebSocket через порт 443. # Причина: порт 1883 заблокирован NSX-T Edge firewall на уровне облака. # Решение: EMQX WebSocket listener (8083) проксируется через nginx-ingress. # # IoT устройство подключается: ws://iot.kube5s.ru/mqtt +# IoT Консоль (UI): http://iot.kube5s.ru/console +# # DNS A-запись: iot.kube5s.ru → 185.247.187.147 (создана через Nubes API, zoneUid=498096ee) # # Когда DevOps откроет порт 1883 — этот файл можно удалить. @@ -42,6 +44,7 @@ spec: - host: iot.kube5s.ru http: paths: + # MQTT over WebSocket — для подключения IoT устройств и браузерного эмулятора - path: /mqtt pathType: Exact backend: @@ -49,3 +52,13 @@ spec: name: emqx-ws port: number: 8083 + # IoT Консоль (UI) — HTML SPA встроенный в бинарник sless-operator + # URL: http://iot.kube5s.ru/console + # Почему здесь: UI работает по HTTP чтобы ws:// (без TLS) не вызывал mixed content + - path: /console + pathType: Exact + backend: + service: + name: sless-operator + port: + number: 9090 diff --git a/deployments/k8s/iot-mqtt-bridge.yaml b/deployments/k8s/iot-mqtt-bridge.yaml index 2060a51..6050c98 100644 --- a/deployments/k8s/iot-mqtt-bridge.yaml +++ b/deployments/k8s/iot-mqtt-bridge.yaml @@ -44,7 +44,7 @@ spec: - name: mqtt-bridge # Тот же образ что и оператор — оба бинаря в одном слое (manager + iot-mqtt-bridge). # При смене версии оператора — менять тег и здесь. - image: pearlharbor.registryk8s.services.ngcloud.ru/naeel/sless-operator:v0.1.50 + image: pearlharbor.registryk8s.services.ngcloud.ru/naeel/sless-operator:v0.1.53 imagePullPolicy: Always command: ["/iot-mqtt-bridge"] env: diff --git a/deployments/k8s/operator.yaml b/deployments/k8s/operator.yaml index e91cfa4..5b39714 100644 --- a/deployments/k8s/operator.yaml +++ b/deployments/k8s/operator.yaml @@ -75,7 +75,7 @@ spec: - name: operator # При обновлении версии оператора — менять тег здесь (не latest!) # v0.1.50 — добавлены IoT controller, IoT REST API, MQTT auth endpoint - image: pearlharbor.registryk8s.services.ngcloud.ru/naeel/sless-operator:v0.1.50 + image: pearlharbor.registryk8s.services.ngcloud.ru/naeel/sless-operator:v0.1.53 # Always — чтобы всегда тянуть по точному тегу (не кешировать старый) imagePullPolicy: Always ports: diff --git a/doc/thinking/2026-04-04-02.md b/doc/thinking/2026-04-04-02.md index 1aaba81..9f0f908 100644 --- a/doc/thinking/2026-04-04-02.md +++ b/doc/thinking/2026-04-04-02.md @@ -213,3 +213,57 @@ namespace: iot-{hash} — tenant IoT (IoTDevice CRDs) - Индекс: `(device_id, ts DESC)` для быстрых запросов по устройству за период - Retention: пока без TTL, добавить позже через pg_partman или cron job + +--- +## 2026-04-04 — IoT Console UI: план и реализация + +**Агент**: GitHub Copilot (Claude Sonnet 4.6) + +### Постановка задачи + +Пользователь сформулировал: нужен UI для управления IoT устройствами. +Причина: не все пользователи работают через Terraform/API напрямую. +Нужно: создать устройство, получить credentials, прошить в устройство, проверить отправку данных. + +### Ключевое решение: эмулятор устройства в браузере + +MQTT WebSocket уже работает: `ws://iot.kube5s.ru:80/mqtt`. +Браузер через mqtt.js (CDN) может подключиться как устройство напрямую. +Это значит: эмулятор — это не "симуляция", а реальная публикация MQTT сообщений. + +Когда у клиента ещё нет физического устройства — он тестирует через эмулятор. +Это закрывает весь цикл без необходимости устанавливать MQTT-клиент. + +### Архитектурные решения UI + +**Стек**: ванильный HTML/CSS/JS + mqtt.js (CDN). Никаких фреймворков. +**Где хранить**: встраиваем в бинарник оператора через `go:embed`. + - Файл: `internal/api/ui/iot-console.html` + - Маршрут: `GET /console` + +**Где доступен**: `http://iot.kube5s.ru/console` + - Ingress добавляем path `/console` → `sless-operator:9090` + +**Почему не `https://sless.kube5s.ru/console`:** + - UI на HTTPS + MQTT WS без TLS = mixed content, браузер блокирует + - UI на HTTP + MQTT WS = нет mixed content, всё работает + - HTTP → HTTPS API вызовы разрешены (это не mixed content) + - Нужен только CORS на API стороне + +**CORS**: заголовки `Access-Control-Allow-Origin: http://iot.kube5s.ru` + OPTIONS preflight + +### Страницы + +1. Вход: API адрес + MQTT брокер + namespace + токен → localStorage +2. Список устройств: таблица, создать, удалить +3. Устройство (3 вкладки): + - Credentials: username, password скрыт, топик, инструкция + - Эмулятор: подключиться → JSON payload → send / авто + - Телеметрия: "скоро" + +### Следующие шаги после UI + +1. Postgres StatefulSet в namespace `iot` +2. INSERT в iot_telemetry из mqtt-bridge +3. REST API для чтения телеметрии +4. Заполнить вкладку "Телеметрия" в UI diff --git a/internal/api/console_embed.go b/internal/api/console_embed.go new file mode 100644 index 0000000..6e45c5f --- /dev/null +++ b/internal/api/console_embed.go @@ -0,0 +1,32 @@ +// Создано: 2026-04-04 +// console_embed.go — встраивает HTML-файл IoT консоли в бинарник оператора через go:embed. +// +// Файл ui/iot-console.html встраивается при компиляции и раздаётся по GET /console. +// Путь /console доступен без JWT — это публичная статическая страница. +// Авторизация в UI происходит через Bearer-токен который пользователь вводит сам. +// +// Почему go:embed а не отдельный nginx: нет лишних pod'ов, единый деплой, нет drift. +// Почему /console без auth: HTML файл не содержит секретов, токен вводит пользователь. + +package api + +import ( + _ "embed" + "net/http" +) + +// iotConsoleHTML — бинарное содержимое IoT консоли, встроенное при сборке. +// При изменении HTML-файла достаточно пересобрать оператор. +// +//go:embed ui/iot-console.html +var iotConsoleHTML []byte + +// ServeIoTConsole обрабатывает GET /console — отдаёт HTML SPA без JWT-проверки. +// Браузер кэширует HTML; API-запросы из JS защищены Bearer-токеном. +func ServeIoTConsole(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(iotConsoleHTML) +} diff --git a/internal/api/router.go b/internal/api/router.go index 35ca04a..35783bb 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -1,7 +1,8 @@ -// Изменено: 2026-03-20 (function-service-split: добавлены /services маршруты) +// Изменено: 2026-04-04 (iot-console: добавлен CORS middleware + /console маршрут) // router.go — регистрация всех REST-маршрутов через gorilla/mux. -// Все маршруты защищены Bearer-токеном (middleware.Auth). -// Маршруты сгруппированы по /v1/namespaces/{namespace}/... +// Все маршруты /v1/ защищены Bearer-токеном (middleware.Auth). +// /console — публичный маршрут (статический HTML без auth). +// CORS включён для http://iot.kube5s.ru — там хостится IoT Консоль (UI). package api @@ -15,12 +16,34 @@ import ( "gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/api/middleware" ) +// corsMiddleware добавляет CORS-заголовки для IoT Консоли на http://iot.kube5s.ru. +// Нужен потому что: консоль на http://iot.kube5s.ru, API на https://sless.kube5s.ru — разные origin. +// Обрабатывает preflight OPTIONS запросы от браузера. +func corsMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "http://iot.kube5s.ru") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type") + w.Header().Set("Access-Control-Max-Age", "600") + // Preflight OPTIONS возвращаем немедленно без передачи дальше + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + next.ServeHTTP(w, r) + }) +} + // NewRouter собирает gorilla/mux роутер со всеми маршрутами. // /fn/{namespace}/{name} — публичный прокси для вызова функций, без auth. -// /v1/ — защищён JWT-аутентификацией (middleware.Auth). +// /console — IoT Консоль (HTML SPA), без auth, с CORS. +// /v1/ — защищён JWT-аутентификацией (middleware.Auth), с CORS. func NewRouter(h *handler.Handler, log *slog.Logger) http.Handler { r := mux.NewRouter() + // IoT Консоль — статический HTML, публично доступен + r.HandleFunc("/console", ServeIoTConsole).Methods(http.MethodGet) + // Публичный прокси для вызова HTTP-триггеров — без auth токена // Все HTTP методы разрешены (GET/POST/PUT/... — решает сама функция) r.PathPrefix("/fn/{namespace}/{name}").HandlerFunc(h.InvokeFunction) @@ -84,12 +107,11 @@ func NewRouter(h *handler.Handler, log *slog.Logger) http.Handler { // Изолирует клиента в пределах его топиков: {namespace}/{deviceId}/# r.HandleFunc("/internal/mqtt/acl", h.MQTTAcl).Methods(http.MethodPost) - // Цепочка middleware: logging → (auth только для /v1/) → router - // /fn/ — без auth, /v1/ — с auth. - // Используем gorilla/mux Use() чтобы auth применялся только к v1 суброутеру. + // Цепочка middleware: CORS → logging → (auth только для /v1/) → router + // /fn/ — без auth, /console — без auth, /v1/ — с auth. v1.Use(func(next http.Handler) http.Handler { return middleware.Auth(log, next) }) - return middleware.Logging(log, r) + return corsMiddleware(middleware.Logging(log, r)) } diff --git a/internal/api/ui/iot-console.html b/internal/api/ui/iot-console.html new file mode 100644 index 0000000..dc42157 --- /dev/null +++ b/internal/api/ui/iot-console.html @@ -0,0 +1,972 @@ + + + +
+ + +