feat: функции со статусом (Ready/Cold/Building/Error)

- Backend: новый файл status.go с функцией определения статуса пода и Package buildStatus
- API: GET /functions теперь возвращает функции с полем status в аннотациях
- Frontend: добавлена колонка Status в таблицу функций с цветными кругами
- Легенда статусов выше таблицы
- Версия v1.3.70
This commit is contained in:
“Naeel”
2026-05-09 06:29:46 +04:00
parent 810980cfcd
commit f6f9382887
6 changed files with 189 additions and 6 deletions
+20
View File
@@ -0,0 +1,20 @@
MQ-триггер (MessageQueue) — запуск при появлении сообщения в очереди (Kafka, NATS, RabbitMQ и т.д.)
Kube-Watch триггер — запуск при изменении объектов в Kubernetes - а это разве может быть реализовано ?
В Fission — да, это штатные встроенные типы триггеров. Fission из коробки поддерживает MQ (Kafka, NATS, Azure Queue) и Kube-Watch через отдельные компоненты (kubewatcher, mqtrigger).
Но в вашей консоли это не реализовано — нет UI для создания/управления этими триггерами. Только HTTP и Cron. Если потребуется — можно добавить.
от что реально полезно и не очень сложно:
Для пользователя:
История вызовов — последние N вызовов функции: время, статус, время ответа. Сейчас вызвал и забыл.
Метрики — счётчик вызовов, средний latency, % ошибок. У вас уже есть metrics-collector — данные есть, нужен только UI.
Автообновление логов — сейчас нужно жать "Обновить" вручную. Можно автополлинг каждые 3-5 сек.
Копировать маршрут одной кнопкой — иконка 📋 рядом с маршрутом в таблице.
Статус функции — Ready/NotReady/Error прямо в таблице (сейчас не видно).
Для управления:
6. Клонировать функцию — создать копию с новым именем, сохранив код и настройки.
7. Экспорт/импорт — скачать функцию как zip, загрузить обратно.
8. Переменные окружения — env vars для функции через UI (сейчас нельзя задать).
+1 -1
View File
@@ -55,7 +55,7 @@ spec:
serviceAccountName: fission-console
containers:
- name: console
image: naeel/fission-console:v1.3.69
image: naeel/fission-console:v1.3.70
imagePullPolicy: Always
ports:
- containerPort: 8090
+2 -2
View File
@@ -51,11 +51,11 @@ const fissionSourceTypeAnnotation = "fission-console/source-type"
const defaultFunctionInvokeTimeout = 60 * time.Second
// handleFunctionsRoot обрабатывает запросы к /console/api/functions без имени функции.
// GET → список всех функций, POST → создать новую.
// GET → список всех функций со статусами, POST → создать новую.
func (s *Server) handleFunctionsRoot(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
s.handleList(fission.FunctionGVR)(w, r)
s.handleListFunctionsWithStatus(w, r)
case http.MethodPost:
s.handleCreateFunction(w, r)
default:
+135
View File
@@ -0,0 +1,135 @@
// Package api — определение статуса функции.
//
// Этот файл содержит функции для определения статуса функции на основе:
// - buildStatus пакета (Package CRD)
// - состояния pod-а (kubernetes Pod)
//
// Возможные статусы:
// - "Ready" (зелёный) — Pod в состоянии Running
// - "Cold" (синий) — функция готова, но нет активного Pod
// - "Building" (жёлтый) — Package ещё собирается (buildStatus != "succeeded")
// - "Error" (красный) — Pod в состоянии ошибки (CrashLoopBackOff, ImagePullBackOff и т.д.) или build failed
package api
import (
"context"
"fmt"
"net/http"
"time"
"fission-console/internal/fission"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)
// FunctionStatus — расширенный объект Function с полем status.
type FunctionStatus struct {
// Встраиваем исходный объект и добавляем status
Object map[string]any `json:"object,inline"`
Status string `json:"status"` // "Ready", "Cold", "Building", "Error"
}
// getFunctionStatus определяет статус функции на основе pod-ов и Package.
func (s *Server) getFunctionStatus(ctx context.Context, ns string, fn *unstructured.Unstructured) (string, error) {
fnName := fn.GetName()
// Шаг 1: проверяем buildStatus пакета (если есть)
packageName, _, _ := unstructured.NestedString(fn.Object, "spec", "package", "packageref", "name")
if packageName != "" {
pkg, pkgErr := s.dyn.Resource(fission.PackageGVR).Namespace(ns).Get(ctx, packageName, metav1.GetOptions{})
if pkgErr == nil {
buildStatus, _, _ := unstructured.NestedString(pkg.Object, "status", "buildstatus")
if buildStatus == "failed" {
return "Error", nil
}
if buildStatus != "succeeded" && buildStatus != "" {
// Building, Pending и т.д.
return "Building", nil
}
}
}
// Шаг 2: ищем Pod-ы для этой функции (по лейблу functionName=<name>)
pods, err := s.kube.CoreV1().Pods(ns).List(ctx, metav1.ListOptions{
LabelSelector: fmt.Sprintf("functionName=%s", fnName),
})
if err != nil && !apierrors.IsNotFound(err) {
// Логируем ошибку, но не падаем — возвращаем "Cold" (функция есть, но pod-а нет)
return "Cold", nil
}
// Шаг 3: анализируем состояние pod-ов
if pods != nil && len(pods.Items) > 0 {
for _, pod := range pods.Items {
// Проверяем фазу pod-а
phase := pod.Status.Phase
if phase == "Running" {
// Дополнительная проверка: все контейнеры готовы?
allReady := true
for _, status := range pod.Status.ContainerStatuses {
if !status.Ready {
allReady = false
break
}
}
if allReady {
return "Ready", nil
}
}
// Проверяем состояние контейнеров (CrashLoopBackOff, ImagePullBackOff и т.д.)
for _, status := range pod.Status.ContainerStatuses {
if status.State.Waiting != nil && status.State.Waiting.Reason == "CrashLoopBackOff" {
return "Error", nil
}
if status.State.Waiting != nil && status.State.Waiting.Reason == "ImagePullBackOff" {
return "Error", nil
}
if status.LastTerminationState.Terminated != nil && status.LastTerminationState.Terminated.ExitCode != 0 {
return "Error", nil
}
}
}
// Pod есть, но не в Running или Error состоянии → Building/Deploying
return "Building", nil
}
// Нет pod-ов → Cold (функция холодная, но готова)
return "Cold", nil
}
// handleListFunctionsWithStatus возвращает список функций с полем "status".
// Добавляет status в поле .metadata.annotations["fission-console/status"] каждой функции.
func (s *Server) handleListFunctionsWithStatus(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) // 30 сек для определения статуса всех функций
defer cancel()
ns := s.userNS(r)
// Получаем список функций
list, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).List(ctx, metav1.ListOptions{})
if err != nil {
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("list functions: %v", err))
return
}
// Обогащаем каждую функцию статусом (добавляем в .metadata.annotations["fission-console/status"])
for i := range list.Items {
status, _ := s.getFunctionStatus(ctx, ns, &list.Items[i])
anns := list.Items[i].GetAnnotations()
if anns == nil {
anns = make(map[string]string)
}
anns["fission-console/status"] = status
list.Items[i].SetAnnotations(anns)
}
writeJSON(w, http.StatusOK, list.Items)
}
+15 -2
View File
@@ -102,7 +102,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.69</div>
<div style="font-size:0.65rem; color:var(--text-secondary); margin-left:10px; align-self:center; opacity:0.7;">v1.3.70</div>
</div>
<div class="row" style="margin:0;">
<button class="btn ghost" onclick="reloadAll()">Refresh</button>
@@ -142,10 +142,23 @@
<div style="font-weight:600;">Функции</div>
<div class="hint">Actions: view, edit code, invoke, delete</div>
</div>
<!-- Легенда статусов -->
<div style="margin-bottom:12px; padding:8px; background:var(--bg-alt); border-radius:4px; font-size:0.9em; color:var(--text-secondary);">
<strong>Статусы функций:</strong>
<span style="margin-left:16px;">
<span style="display:inline-block; width:10px; height:10px; border-radius:50%; background:#2a2; vertical-align:middle;"></span> Готова
<span style="margin-left:12px; display:inline-block; width:10px; height:10px; border-radius:50%; background:#22a; vertical-align:middle;"></span> Холодная
<span style="margin-left:12px; display:inline-block; width:10px; height:10px; border-radius:50%; background:#aa2; vertical-align:middle;"></span> Сборка
<span style="margin-left:12px; display:inline-block; width:10px; height:10px; border-radius:50%; background:#a22; vertical-align:middle;"></span> Ошибка
</span>
</div>
<table>
<thead>
<tr>
<th>Имя</th>
<th title="Статус функции (Готова / Холодная / Сборка / Ошибка)">Статус</th>
<th title="Тип источника функции">Тип</th>
<th>Создана</th>
<th>Изменена</th>
@@ -485,7 +498,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.69</span>
<span style="font-size:0.75rem; color:var(--text-secondary);">v1.3.70</span>
<button class="btn ghost" onclick="closeHelp()">Закрыть</button>
</div>
</div>
+16 -1
View File
@@ -48,6 +48,20 @@ async function reloadAll() {
const createdAt = ann['fission-console/created-at'] || meta.creationTimestamp || '-';
const updatedAt = ann['fission-console/updated-at'] || createdAt;
const sourceType = ann['fission-console/source-type'] || 'code';
const status = ann['fission-console/status'] || 'Cold';
// Статус функции: иконка и цвет
const statusColor = {
'Ready': '#2a2', 'Cold': '#22a', 'Building': '#aa2', 'Error': '#a22'
}[status] || '#666';
const statusTitle = {
'Ready': 'Готова (есть pod)',
'Cold': 'Холодная (pod создаётся при первом вызове)',
'Building': 'Собирается / деплоится',
'Error': 'Ошибка (pod в CrashLoopBackOff или build failed)'
}[status] || 'Неизвестно';
const statusDot = '<span style="display:inline-block; width:12px; height:12px; border-radius:50%; background:' + statusColor + '; cursor:default;" title="' + h(statusTitle) + '"></span>';
const sourceIcon = sourceType === 'archive'
? '<span title="Из архива (.zip)" style="font-size:1.1em; cursor:default;">📦</span>'
: '<span title="Из кода (редактор)" style="font-size:1.1em; cursor:default;">📝</span>';
@@ -58,7 +72,7 @@ async function reloadAll() {
var infoData = h(JSON.stringify({
name: name, env: env, pkg: pkg, entrypoint: entrypoint, timeout: timeout,
route: route, methods: methods, sourceType: sourceType,
createdAt: createdAt, updatedAt: updatedAt, cron: cron
createdAt: createdAt, updatedAt: updatedAt, cron: cron, status: status
}));
var actions = tfBadge +
'<button class="btn ghost" onclick="openInfo(\'' + h(name) + '\', this)" data-info="' + infoData + '">Info</button> ' +
@@ -68,6 +82,7 @@ async function reloadAll() {
'<button class="btn danger" onclick="removeFn(\'' + h(name) + '\')">Удалить</button>';
return '<tr>' +
'<td class="mono">' + h(name) + '</td>' +
'<td style="text-align:center;">' + statusDot + '</td>' +
'<td style="text-align:center;">' + sourceIcon + '</td>' +
'<td>' + timestampCell(createdAt) + '</td>' +
'<td>' + timestampCell(updatedAt) + '</td>' +