feat: weather-demo MQ pipeline + sqs-consumer v1.2 with JWT auto-refresh

- sqs-consumer: new Go binary v1.2 with tokenManager (auto-login /auth/login, 5min cache, retry on 401)
- console: add MQ/KW triggers UI (mqtriggers.go, kwtriggers.go, mq.js)
- console: update python-env image to v1.1 (boto3+psycopg2+requests)
- weather-demo: fix consumer/main.py (Flask Request.get_json instead of dict access)
- weather-demo: fix fetcher/main.py (boto3 SQS publish, 5 cities)
- weather-demo: update main.tf (python-env v1.1, deploy_type=literal)
- python-env: add psycopg2-binary to Dockerfile (v1.1)
- terraform provider: client auth fix
This commit is contained in:
“Naeel”
2026-05-12 11:19:30 +04:00
parent fbd565651a
commit df16b40a9d
18 changed files with 1126 additions and 56 deletions
+5 -2
View File
@@ -15,9 +15,12 @@ rules:
- apiGroups: [""]
resources: ["pods/log"]
verbs: ["get"]
- apiGroups: [""]
resources: ["secrets"]
verbs: ["get", "list", "create", "delete"]
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["get", "list", "update", "patch"]
verbs: ["get", "list", "create", "update", "patch", "delete"]
- apiGroups: ["fission.io"]
resources: ["environments", "packages", "functions", "httptriggers", "timetriggers"]
verbs: ["get", "list", "create", "update", "patch", "delete"]
@@ -55,7 +58,7 @@ spec:
serviceAccountName: fission-console
containers:
- name: console
image: naeel/fission-console:v1.3.87
image: naeel/fission-console:v1.3.88
imagePullPolicy: Always
ports:
- containerPort: 8090
+241
View File
@@ -0,0 +1,241 @@
package api
// kwtriggers.go — CRUD хендлеры для KubernetesWatchTrigger (Fission KW Trigger).
//
// РЕШЕНИЕ ПО АРХИТЕКТУРЕ (2026-05-11):
// KubernetesWatchTrigger позволяет вызывать функцию при изменении K8s объектов.
// spec.type — тип ресурса: Pod, Service, Deployment, ConfigMap, и т.д.
// spec.namespace — namespace для слежения (по умолчанию = namespace пользователя)
// spec.labelselector — label selector в формате "key=value,key2=value2"
// spec.functionref — ссылка на функцию
//
// ОСОБЕННОСТИ:
// - Fission kubewatcher компонент должен быть задеплоен.
// - namespace в spec — это WATCHED namespace (не namespace триггера).
// Для безопасности ограничиваем: только namespace пользователя или пустое (тогда = userNS).
// - labelselector опционален, "" = смотрим на все ресурсы типа resourceType в namespace.
//
// ОШИБКИ В ПРОЦЕССЕ:
// - (нет, первая реализация)
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"fission-console/internal/fission"
"fission-console/internal/model"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)
// validKWResourceTypes — поддерживаемые типы ресурсов для слежения.
// Расширяемо — это не ограничение CRD, просто UI-валидация.
var validKWResourceTypes = map[string]struct{}{
"pod": {},
"service": {},
"deployment": {},
"configmap": {},
"secret": {},
"namespace": {},
"replicaset": {},
"statefulset": {},
"daemonset": {},
"job": {},
}
func (s *Server) handleKWTriggersRoot(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
s.handleList(fission.KWTrigGVR)(w, r)
case http.MethodPost:
s.handleCreateKWTrigger(w, r)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (s *Server) handleKWTriggersAction(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/console/api/kwtriggers/")
path = strings.TrimPrefix(path, "/api/kwtriggers/")
name := strings.Trim(path, "/")
if name == "" || strings.Contains(name, "/") {
http.NotFound(w, r)
return
}
switch r.Method {
case http.MethodGet:
s.handleGetKWTrigger(w, r, name)
case http.MethodDelete:
s.handleDeleteKWTrigger(w, r, name)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (s *Server) handleCreateKWTrigger(w http.ResponseWriter, r *http.Request) {
var req model.CreateKWTriggerRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("decode request: %v", err))
return
}
ns := s.userNS(r)
// Если namespace не задан — используем namespace пользователя
if strings.TrimSpace(req.Namespace) == "" {
req.Namespace = ns
}
// Безопасность: нельзя смотреть за чужим namespace
if req.Namespace != ns {
writeJSONError(w, http.StatusForbidden, "can only watch your own namespace")
return
}
if err := validateKWTriggerRequest(req); err != nil {
writeJSONError(w, http.StatusBadRequest, err.Error())
return
}
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
// Проверяем что функция существует
if _, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Get(ctx, req.FunctionName, metav1.GetOptions{}); err != nil {
if apierrors.IsNotFound(err) {
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("function %q not found", req.FunctionName))
return
}
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("get function: %v", err))
return
}
obj := buildKWTriggerObject(ns, req)
created, err := s.dyn.Resource(fission.KWTrigGVR).Namespace(ns).Create(ctx, obj, metav1.CreateOptions{})
if err != nil {
if apierrors.IsAlreadyExists(err) {
writeJSONError(w, http.StatusConflict, fmt.Sprintf("kwtrigger %q already exists", req.Name))
return
}
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("create kwtrigger: %v", err))
return
}
writeAnyJSON(w, http.StatusCreated, kwTriggerResponse(created))
}
func (s *Server) handleGetKWTrigger(w http.ResponseWriter, r *http.Request, name string) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
obj, err := s.dyn.Resource(fission.KWTrigGVR).Namespace(s.userNS(r)).Get(ctx, name, metav1.GetOptions{})
if err != nil {
status := http.StatusBadGateway
if apierrors.IsNotFound(err) {
status = http.StatusNotFound
}
writeJSONError(w, status, fmt.Sprintf("get kwtrigger %q: %v", name, err))
return
}
writeAnyJSON(w, http.StatusOK, kwTriggerResponse(obj))
}
func (s *Server) handleDeleteKWTrigger(w http.ResponseWriter, r *http.Request, name string) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
if err := s.dyn.Resource(fission.KWTrigGVR).Namespace(s.userNS(r)).Delete(ctx, name, metav1.DeleteOptions{}); err != nil {
status := http.StatusBadGateway
if apierrors.IsNotFound(err) {
status = http.StatusNotFound
}
writeJSONError(w, status, fmt.Sprintf("delete kwtrigger %q: %v", name, err))
return
}
writeAnyJSON(w, http.StatusOK, map[string]any{"deleted": true, "name": name})
}
// --- Вспомогательные функции ---
func validateKWTriggerRequest(req model.CreateKWTriggerRequest) error {
if strings.TrimSpace(req.Name) == "" {
return fmt.Errorf("name is required")
}
if strings.TrimSpace(req.FunctionName) == "" {
return fmt.Errorf("functionName is required")
}
rt := strings.ToLower(strings.TrimSpace(req.ResourceType))
if _, ok := validKWResourceTypes[rt]; !ok {
return fmt.Errorf("resourceType must be one of: Pod, Service, Deployment, ConfigMap, Secret, Namespace, ReplicaSet, StatefulSet, DaemonSet, Job")
}
return nil
}
func buildKWTriggerObject(ns string, req model.CreateKWTriggerRequest) *unstructured.Unstructured {
// Fission ожидает capitalize: Pod, Service, Deployment
resourceType := capitalize(strings.TrimSpace(req.ResourceType))
spec := map[string]any{
"type": resourceType,
"namespace": req.Namespace,
"functionref": map[string]any{
"type": "name",
"name": req.FunctionName,
},
}
if req.LabelSelector != "" {
spec["labelselector"] = req.LabelSelector
}
return &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "fission.io/v1",
"kind": "KubernetesWatchTrigger",
"metadata": map[string]any{
"name": req.Name,
"namespace": ns,
},
"spec": spec,
}}
}
func kwTriggerResponse(obj *unstructured.Unstructured) map[string]any {
spec, _ := obj.Object["spec"].(map[string]any)
if spec == nil {
spec = map[string]any{}
}
fnref, _ := spec["functionref"].(map[string]any)
fnName := ""
if fnref != nil {
fnName, _ = fnref["name"].(string)
}
return map[string]any{
"metadata": map[string]any{
"name": obj.GetName(),
"namespace": obj.GetNamespace(),
},
"spec": map[string]any{
"resourceType": spec["type"],
"namespace": spec["namespace"],
"labelSelector": spec["labelselector"],
"functionName": fnName,
},
}
}
// capitalize приводит первый символ к верхнему регистру, остальное без изменений.
// "pod" → "Pod", "deployment" → "Deployment"
func capitalize(s string) string {
if s == "" {
return s
}
return strings.ToUpper(s[:1]) + strings.ToLower(s[1:])
}
+286
View File
@@ -0,0 +1,286 @@
package api
// mqtriggers.go — CRUD для MQ-триггеров через K8s Deployment + Secret.
//
// АРХИТЕКТУРА (2026-05-11):
// Вместо Fission MessageQueueTrigger CRD (требует mqtrigger компонент Kafka/NATS)
// Console деплоит собственный sqs-consumer Deployment в namespace пользователя.
//
// При CREATE:
// 1. Создаём K8s Secret (sqs-mq-<name>) с SQS credentials
// 2. Создаём K8s Deployment (mq-<name>) с образом naeel/sqs-consumer:v1.0
// FUNCTION_URL = http://router.fission.svc.cluster.local/<functionName>
// Лейблы: app.kubernetes.io/managed-by=fission-console, component=mq-trigger
//
// При LIST: deployments -n <ns> -l component=mq-trigger
// При DELETE: удаляем Deployment + Secret
//
// ИЗМЕНЕНИЯ:
// v1 — использовал Fission MQ CRD (компонент отсутствует в кластере)
// v2 — K8s Deployment + наш sqs-consumer образ
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"fission-console/internal/model"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
const (
sqsConsumerImage = "naeel/sqs-consumer:v1.0"
sqsDefaultEndpoint = "http://shared-sqs.shared-sqs.svc.cluster.local:4100"
fissionRouterBase = "http://router.fission.svc.cluster.local"
mqTriggerLabelKey = "component"
mqTriggerLabelVal = "mq-trigger"
mqManagedByLabel = "app.kubernetes.io/managed-by"
mqManagedByVal = "fission-console"
)
func mqSecretName(name string) string { return "sqs-mq-" + name }
func mqDeployName(name string) string { return "mq-" + name }
// ── HTTP хендлеры ────────────────────────────────────────────────────
func (s *Server) handleMQTriggersRoot(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
s.handleListMQTriggers(w, r)
case http.MethodPost:
s.handleCreateMQTrigger(w, r)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (s *Server) handleMQTriggersAction(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/console/api/mqtriggers/")
name := strings.Trim(path, "/")
if name == "" || strings.Contains(name, "/") {
http.NotFound(w, r)
return
}
switch r.Method {
case http.MethodDelete:
s.handleDeleteMQTrigger(w, r, name)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
// ── LIST ────────────────────────────────────────────────────────────
func (s *Server) handleListMQTriggers(w http.ResponseWriter, r *http.Request) {
ns := s.userNS(r)
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
labelSel := fmt.Sprintf("%s=%s,%s=%s", mqManagedByLabel, mqManagedByVal, mqTriggerLabelKey, mqTriggerLabelVal)
deployList, err := s.kube.AppsV1().Deployments(ns).List(ctx, metav1.ListOptions{LabelSelector: labelSel})
if err != nil {
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("list mq deployments: %v", err))
return
}
items := make([]map[string]any, 0, len(deployList.Items))
for i := range deployList.Items {
items = append(items, mqDeployToResponse(&deployList.Items[i]))
}
writeAnyJSON(w, http.StatusOK, map[string]any{"items": items})
}
// ── CREATE ──────────────────────────────────────────────────────────
func (s *Server) handleCreateMQTrigger(w http.ResponseWriter, r *http.Request) {
var req model.CreateMQTriggerRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONError(w, http.StatusBadRequest, "decode request: "+err.Error())
return
}
if err := validateMQRequest(req); err != nil {
writeJSONError(w, http.StatusBadRequest, err.Error())
return
}
ns := s.userNS(r)
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
endpoint := strings.TrimSpace(req.SqsEndpoint)
if endpoint == "" {
endpoint = sqsDefaultEndpoint
}
functionURL := fissionRouterBase + "/" + req.FunctionName
secretName := mqSecretName(req.Name)
deployName := mqDeployName(req.Name)
// 1. Secret с SQS credentials
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: secretName,
Namespace: ns,
Labels: map[string]string{
mqManagedByLabel: mqManagedByVal,
mqTriggerLabelKey: mqTriggerLabelVal,
"mq-trigger-name": req.Name,
},
},
StringData: map[string]string{
"SQS_ACCESS_KEY": req.AccessKey,
"SQS_SECRET_KEY": req.SecretKey,
"SQS_ENDPOINT": endpoint,
},
}
if _, err := s.kube.CoreV1().Secrets(ns).Create(ctx, secret, metav1.CreateOptions{}); err != nil {
if apierrors.IsAlreadyExists(err) {
writeJSONError(w, http.StatusConflict, fmt.Sprintf("mq trigger %q already exists", req.Name))
return
}
writeJSONError(w, http.StatusBadGateway, "create secret: "+err.Error())
return
}
// 2. Deployment (sqs-consumer)
replicas := int32(1)
deploy := &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Name: deployName,
Namespace: ns,
Labels: map[string]string{
mqManagedByLabel: mqManagedByVal,
mqTriggerLabelKey: mqTriggerLabelVal,
"mq-trigger-name": req.Name,
},
Annotations: map[string]string{
"fission-console/mq-trigger-name": req.Name,
"fission-console/function": req.FunctionName,
"fission-console/queue": req.Queue,
"fission-console/sqs-endpoint": endpoint,
},
},
Spec: appsv1.DeploymentSpec{
Replicas: &replicas,
Selector: &metav1.LabelSelector{
MatchLabels: map[string]string{"mq-trigger-name": req.Name},
},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{
"mq-trigger-name": req.Name,
mqTriggerLabelKey: mqTriggerLabelVal,
},
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{{
Name: "sqs-consumer",
Image: sqsConsumerImage,
ImagePullPolicy: corev1.PullAlways,
Env: []corev1.EnvVar{
{Name: "SQS_QUEUE_NAME", Value: req.Queue},
{Name: "SQS_REGION", Value: "us-east-1"},
{Name: "FUNCTION_URL", Value: functionURL},
{Name: "POLL_INTERVAL", Value: "5"},
{Name: "MAX_MESSAGES", Value: "1"},
{Name: "MAX_RETRIES", Value: "3"},
},
EnvFrom: []corev1.EnvFromSource{{
SecretRef: &corev1.SecretEnvSource{
LocalObjectReference: corev1.LocalObjectReference{Name: secretName},
},
}},
Resources: corev1.ResourceRequirements{
Limits: corev1.ResourceList{
corev1.ResourceCPU: resource.MustParse("50m"),
corev1.ResourceMemory: resource.MustParse("32Mi"),
},
Requests: corev1.ResourceList{
corev1.ResourceCPU: resource.MustParse("10m"),
corev1.ResourceMemory: resource.MustParse("16Mi"),
},
},
}},
},
},
},
}
created, err := s.kube.AppsV1().Deployments(ns).Create(ctx, deploy, metav1.CreateOptions{})
if err != nil {
_ = s.kube.CoreV1().Secrets(ns).Delete(ctx, secretName, metav1.DeleteOptions{})
writeJSONError(w, http.StatusBadGateway, "create deployment: "+err.Error())
return
}
writeAnyJSON(w, http.StatusCreated, mqDeployToResponse(created))
}
// ── DELETE ──────────────────────────────────────────────────────────
func (s *Server) handleDeleteMQTrigger(w http.ResponseWriter, r *http.Request, name string) {
ns := s.userNS(r)
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
dErr := s.kube.AppsV1().Deployments(ns).Delete(ctx, mqDeployName(name), metav1.DeleteOptions{})
sErr := s.kube.CoreV1().Secrets(ns).Delete(ctx, mqSecretName(name), metav1.DeleteOptions{})
if dErr != nil && !apierrors.IsNotFound(dErr) {
writeJSONError(w, http.StatusBadGateway, "delete deployment: "+dErr.Error())
return
}
if sErr != nil && !apierrors.IsNotFound(sErr) {
writeJSONError(w, http.StatusBadGateway, "delete secret: "+sErr.Error())
return
}
writeAnyJSON(w, http.StatusOK, map[string]any{"deleted": true, "name": name})
}
// ── Вспомогательные ─────────────────────────────────────────────────
func validateMQRequest(req model.CreateMQTriggerRequest) error {
if strings.TrimSpace(req.Name) == "" {
return fmt.Errorf("name is required")
}
if strings.TrimSpace(req.FunctionName) == "" {
return fmt.Errorf("functionName is required")
}
if strings.TrimSpace(req.Queue) == "" {
return fmt.Errorf("queue is required")
}
if strings.TrimSpace(req.AccessKey) == "" {
return fmt.Errorf("accessKey is required")
}
if strings.TrimSpace(req.SecretKey) == "" {
return fmt.Errorf("secretKey is required")
}
return nil
}
func mqDeployToResponse(d *appsv1.Deployment) map[string]any {
ann := d.Annotations
if ann == nil {
ann = map[string]string{}
}
triggerName := ann["fission-console/mq-trigger-name"]
if triggerName == "" {
triggerName = strings.TrimPrefix(d.Name, "mq-")
}
return map[string]any{
"name": triggerName,
"deployName": d.Name,
"functionName": ann["fission-console/function"],
"queue": ann["fission-console/queue"],
"sqsEndpoint": ann["fission-console/sqs-endpoint"],
"ready": d.Status.ReadyReplicas > 0,
"replicas": d.Status.ReadyReplicas,
}
}
+4
View File
@@ -169,6 +169,10 @@ func (s *Server) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("/console/api/httptriggers", auth(s.handleList(fission.HTTPTrigGVR)))
mux.HandleFunc("/console/api/timetriggers", auth(s.handleTimeTriggersRoot))
mux.HandleFunc("/console/api/timetriggers/", auth(s.handleTimeTriggersAction))
mux.HandleFunc("/console/api/mqtriggers", auth(s.handleMQTriggersRoot))
mux.HandleFunc("/console/api/mqtriggers/", auth(s.handleMQTriggersAction))
mux.HandleFunc("/console/api/kwtriggers", auth(s.handleKWTriggersRoot))
mux.HandleFunc("/console/api/kwtriggers/", auth(s.handleKWTriggersAction))
mux.HandleFunc("/console/api/ns/status", auth(s.handleNSStatus))
mux.HandleFunc("/console/api/ns/debug", auth(s.handleNSDebug))
mux.HandleFunc("/console/api/stats/dashboard-url", auth(s.handleStatsDashboard))
+2
View File
@@ -12,6 +12,8 @@ var (
FunctionGVR = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "functions"}
HTTPTrigGVR = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "httptriggers"}
TimeTrigGVR = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "timetriggers"}
MQTrigGVR = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "messagequeuetriggers"}
KWTrigGVR = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "kuberneteswatchtriggers"}
NamespaceGVR = schema.GroupVersionResource{Group: "", Version: "v1", Resource: "namespaces"}
DeploymentGVR = schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployments"}
+22
View File
@@ -26,6 +26,28 @@ type CreateTimeTriggerRequest struct {
SubPath string `json:"subpath"`
}
// CreateMQTriggerRequest — тело POST /console/api/mqtriggers.
// Архитектура: Console создаёт K8s Deployment (sqs-consumer) + Secret с credentials
// в namespace пользователя. sqs-consumer поллит shared-sqs → вызывает Fission-функцию.
type CreateMQTriggerRequest struct {
Name string `json:"name"`
FunctionName string `json:"functionName"`
Queue string `json:"queue"` // имя очереди в SQS
SqsEndpoint string `json:"sqsEndpoint"` // URL SQS сервиса (default: internal shared-sqs)
AccessKey string `json:"accessKey"` // SQS access key тенанта
SecretKey string `json:"secretKey"` // SQS secret key тенанта
}
// CreateKWTriggerRequest — тело POST /console/api/kwtriggers.
// Документация полей: kubectl get crd kuberneteswatchtriggers.fission.io -o json
type CreateKWTriggerRequest struct {
Name string `json:"name"`
FunctionName string `json:"functionName"`
ResourceType string `json:"resourceType"` // Pod, Service, Deployment и т.д.
Namespace string `json:"namespace"` // пустое = namespace пользователя
LabelSelector string `json:"labelSelector"` // "app=foo" или "" для всех
}
// UpdateCodeRequest — тело PUT /console/api/functions/:name/code.
type UpdateCodeRequest struct {
Code string `json:"code"`
+73 -2
View File
@@ -25,6 +25,7 @@
<script src="js/fn-archive.js"></script>
<script src="js/ai.js"></script>
<script src="js/app.js"></script>
<script src="js/mq.js"></script>
</head>
<body>
@@ -102,7 +103,7 @@
<div class="nubes">NUBES</div>
<div class="product">FISSION CONSOLE</div>
</div>
<div style="font-size:0.65rem; color:var(--text-secondary); margin-left:10px; align-self:center; opacity:0.7;">v1.3.87</div>
<div style="font-size:0.65rem; color:var(--text-secondary); margin-left:10px; align-self:center; opacity:0.7;">v1.3.88</div>
</div>
<div class="row" style="margin:0;">
<button class="btn ghost" onclick="reloadAll()">Refresh</button>
@@ -136,6 +137,10 @@
<div class="k">Крон-функции</div>
<div id="cron-count" class="v">-</div>
</div>
<div class="card">
<div class="k">MQ-триггеры</div>
<div id="mq-count" class="v">-</div>
</div>
</div>
<div class="box">
@@ -174,6 +179,72 @@
<div id="status" class="status"></div>
<div class="hint">Изменения применяются напрямую через CRD Fission.</div>
</div>
<!-- MQ-триггеры -->
<div class="box">
<div class="toolbar">
<div style="font-weight:600;">MQ-триггеры</div>
<button class="btn" onclick="openCreateMQ()">+ Добавить</button>
</div>
<table>
<thead>
<tr>
<th>Имя</th>
<th>Очередь</th>
<th>Функция</th>
<th>Endpoint SQS</th>
<th>Статус</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="mq-rows">
<tr><td colspan="6" style="color:var(--fg-muted,#888);text-align:center;">Загрузка...</td></tr>
</tbody>
</table>
<div class="hint">MQ-триггер поллит очередь SQS и вызывает Fission-функцию при появлении сообщений.</div>
</div>
</div>
<!-- Модалка: создать MQ-триггер -->
<div id="mq-create-modal" class="modal">
<div class="panel" style="width:520px;max-width:96vw;">
<h3>📨 Создать MQ-триггер</h3>
<div class="row">
<div class="field">
<label>Имя триггера</label>
<input id="mq-name" placeholder="weather-trigger">
</div>
<div class="field">
<label>Функция</label>
<input id="mq-fn" placeholder="weather-store">
</div>
</div>
<div class="row">
<div class="field">
<label>Имя очереди (SQS)</label>
<input id="mq-queue" placeholder="weather-raw">
</div>
<div class="field">
<label>SQS Endpoint</label>
<input id="mq-endpoint" placeholder="http://shared-sqs.shared-sqs.svc.cluster.local:4100">
</div>
</div>
<div class="row">
<div class="field">
<label>Access Key</label>
<input id="mq-access-key" placeholder="SSAK-...">
</div>
<div class="field">
<label>Secret Key</label>
<input id="mq-secret-key" type="password" placeholder="...">
</div>
</div>
<div id="mq-create-error" class="modal-error"></div>
<div class="actions">
<button class="btn ghost" onclick="closeMQCreate()">Отмена</button>
<button class="btn" onclick="submitCreateMQ()">Создать</button>
</div>
</div>
</div>
<!-- Модалка: создать функцию из кода (prefix cc-) -->
@@ -543,7 +614,7 @@
</div>
<div class="actions" style="justify-content:space-between; align-items:center;">
<span style="font-size:0.75rem; color:var(--text-secondary);">v1.3.87</span>
<span style="font-size:0.75rem; color:var(--text-secondary);">v1.3.88</span>
<button class="btn ghost" onclick="closeHelp()">Закрыть</button>
</div>
</div>
+3
View File
@@ -16,6 +16,9 @@ async function reloadAll() {
getJSON(API_BASE + '/timetriggers')
]);
// MQ-триггеры загружаем параллельно, не блокируем основную таблицу
if (typeof loadMQTriggers === 'function') loadMQTriggers();
S.envs = envs || [];
S.fns = fns || [];
S.httpTriggers = http || [];
+103
View File
@@ -0,0 +1,103 @@
// mq.js — MQ-триггеры (SQS → Fission function)
// Архитектура: Console создаёт K8s Deployment + Secret (sqs-consumer) в namespace пользователя.
// sqs-consumer поллит SQS очередь → HTTP POST в Fission-функцию → DeleteMessage.
// Backend: POST /console/api/mqtriggers, DELETE /console/api/mqtriggers/{name}
// v1.3.88
// ── Загрузка и отрисовка ────────────────────────────────────────────
async function loadMQTriggers() {
try {
const data = await apiFetch('/console/api/mqtriggers');
renderMQTable(data.items || []);
const cnt = document.getElementById('mq-count');
if (cnt) cnt.textContent = (data.items || []).length;
} catch (e) {
renderMQTable([]);
const cnt = document.getElementById('mq-count');
if (cnt) cnt.textContent = '0';
}
}
function renderMQTable(items) {
const tbody = document.getElementById('mq-rows');
if (!tbody) return;
if (!items.length) {
tbody.innerHTML = '<tr><td colspan="6" style="color:var(--fg-muted,#888);text-align:center;">Нет MQ-триггеров</td></tr>';
return;
}
tbody.innerHTML = items.map(t => `
<tr>
<td>${escHtml(t.name)}</td>
<td>${escHtml(t.queue)}</td>
<td>${escHtml(t.functionName)}</td>
<td style="font-size:0.8em;color:var(--fg-muted,#aaa);">${escHtml(t.sqsEndpoint || '')}</td>
<td><span style="color:${t.ready ? '#4a4' : '#a44'}">${t.ready ? '▶ Running' : '◼ Pending'}</span></td>
<td>
<button class="btn ghost small" onclick="deleteMQTrigger('${escHtml(t.name)}')">Удалить</button>
</td>
</tr>
`).join('');
}
// ── Создание ─────────────────────────────────────────────────────────
function openCreateMQ() {
document.getElementById('mq-name').value = '';
document.getElementById('mq-fn').value = '';
document.getElementById('mq-queue').value = '';
document.getElementById('mq-endpoint').value = 'http://shared-sqs.shared-sqs.svc.cluster.local:4100';
document.getElementById('mq-access-key').value = '';
document.getElementById('mq-secret-key').value = '';
document.getElementById('mq-create-error').textContent = '';
document.getElementById('mq-create-modal').style.display = 'flex';
}
function closeMQCreate() {
document.getElementById('mq-create-modal').style.display = 'none';
}
async function submitCreateMQ() {
const name = document.getElementById('mq-name').value.trim();
const fnName = document.getElementById('mq-fn').value.trim();
const queue = document.getElementById('mq-queue').value.trim();
const endpoint = document.getElementById('mq-endpoint').value.trim();
const accessKey = document.getElementById('mq-access-key').value.trim();
const secretKey = document.getElementById('mq-secret-key').value.trim();
const errEl = document.getElementById('mq-create-error');
if (!name || !fnName || !queue || !accessKey || !secretKey) {
errEl.textContent = 'Заполните все поля';
return;
}
errEl.textContent = '';
try {
await apiFetch('/console/api/mqtriggers', {
method: 'POST',
body: JSON.stringify({ name, functionName: fnName, queue, sqsEndpoint: endpoint, accessKey, secretKey })
});
closeMQCreate();
await loadMQTriggers();
} catch (e) {
errEl.textContent = e.message || 'Ошибка создания';
}
}
// ── Удаление ─────────────────────────────────────────────────────────
async function deleteMQTrigger(name) {
if (!confirm(`Удалить MQ-триггер "${name}"?`)) return;
try {
await apiFetch(`/console/api/mqtriggers/${encodeURIComponent(name)}`, { method: 'DELETE' });
await loadMQTriggers();
} catch (e) {
alert('Ошибка удаления: ' + (e.message || e));
}
}
// ── Утилита ──────────────────────────────────────────────────────────
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}