diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index ce9405c..8e4b84e 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,13 +1,12 @@ # Правила ## ⛔ ОТВЕЧАТЬ КРАТКО — АБСОЛЮТНОЕ ПРАВИЛО -- Вопрос → короткий ответ → СТОП. Не рассуждать, не исследовать без команды. -- НЕ делать ничего "попутно" без явной просьбы. Сделал — стоп — ждёшь команды. -- Без приветствий и лишних рассуждений. Код — только по запросу. Не бормотать. - -> Подробные правила: [`.github/pravila.md`](pravila.md) +- Вопрос → короткий ответ → СТОП. +- Ничего лишнего. +- Код — только по запросу. 1. Не трогать рабочий код без явного указания. +<<<<<<< HEAD 2. Файлы редактировать локально — `~/remote_dev/fission` = `~/terra/fission` на ВМ (sshfs), SCP не нужен. Перед любым запуском команд проверять, что локальные файлы и содержимое на ВМ синхронизированы; при сомнении ориентироваться на состояние файлов на ВМ. 3. Все команды — **только через SSH**, никогда локально: @@ -17,3 +16,48 @@ не надо без необходимости выдавать цепочки команд, не надо открывать интерактивные сессии, не надо запускать команды локально. 4. **ЗАПРЕЩЕНО** откатывать код, версии, образы и рабочее состояние без явной команды пользователя. 5. После каждого исправления делать отдельный commit через SSH. +======= + +2. Файлы редактируются локально: + ~/fission + + После ЛЮБЫХ изменений ОБЯЗАТЕЛЬНО синхронизировать на ВМ командой: + rsync -az \ + -e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10" \ + ~/fission/ \ + naeel@5.172.178.213:~/terra/fission/ + +3. Все команды выполнять ТОЛЬКО через SSH: + ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10 naeel@5.172.178.213 'КОМАНДА' + + - не выполнять команды локально + - не открывать интерактивные сессии + - не делать цепочки без необходимости + +4. Перед запуском команд ОБЯЗАТЕЛЬНО убедиться, что синхронизация выполнена. + +5. ЗАПРЕЩЕНО: + - откатывать код + - менять версии + - ломать рабочее состояние + +6. После каждого исправления: + - синхронизация (rsync) + - затем commit через SSH + +## ⛔⛔⛔ ДЕЛАТЬ ТОЛЬКО ЧТО ПРЯМО ПРИКАЗАНО + +**АБСОЛЮТНЫЙ ЗАПРЕТ на додумывание:** +- Не расширять масштаб работы +- Не выполнять "логичные следующие шаги" +- Не инициировать дополнительные операции +- Не делать ничего кроме того что сказано + +**Пример (2026-05-01):** +- Приказано: "собери" +- Сделано: ✓ собрал образы v1.3.17 и v0.1.2 +- СТОП — жду команды дальше +- **ЗАПРЕЩЕНО:** обновлять манифесты, заливать образы, применять на кластер, запускать тесты + +**Исключение:** только если приказ явно включает цепочку ("собери И залей И тесты") +>>>>>>> d21e1ac (feat: restore cron page, email in navbar, metrics pipeline v1.3.19) diff --git a/console/deploy/console.yaml b/console/deploy/console.yaml index 204e9b5..14d0acd 100644 --- a/console/deploy/console.yaml +++ b/console/deploy/console.yaml @@ -52,7 +52,7 @@ spec: serviceAccountName: fission-console containers: - name: console - image: naeel/fission-console:v1.3.16 + image: naeel/fission-console:v1.3.19 ports: - containerPort: 8090 env: diff --git a/console/internal/api/cron_metrics.go b/console/internal/api/cron_metrics.go new file mode 100644 index 0000000..5869b9b --- /dev/null +++ b/console/internal/api/cron_metrics.go @@ -0,0 +1,203 @@ +package api + +import ( + "encoding/json" + "net/http" + "os" + "strconv" + "strings" + "sync" + "time" +) + +type CronMetric struct { + Timestamp string `json:"timestamp"` + Source string `json:"source"` + Hostname string `json:"hostname,omitempty"` + Status string `json:"status,omitempty"` + MemoryTotalMB float64 `json:"memory_total_mb,omitempty"` + MemoryFreeMB float64 `json:"memory_free_mb,omitempty"` + MemoryAvailMB float64 `json:"memory_available_mb,omitempty"` + MemoryUsedMB float64 `json:"memory_used_mb,omitempty"` + MemoryPercent float64 `json:"memory_percent,omitempty"` + CpuLoad1m float64 `json:"cpu_load_1m,omitempty"` + CpuLoad5m float64 `json:"cpu_load_5m,omitempty"` + CpuLoad15m float64 `json:"cpu_load_15m,omitempty"` + DiskTotalGB float64 `json:"disk_total_gb,omitempty"` + DiskFreeGB float64 `json:"disk_free_gb,omitempty"` + DiskUsedGB float64 `json:"disk_used_gb,omitempty"` + UptimeSec float64 `json:"uptime_sec,omitempty"` + Notes string `json:"notes,omitempty"` +} + +var ( + cronMetricMu sync.RWMutex + cronMetricHistory []CronMetric + cronMetricMaxKeep = 240 +) + +func (s *Server) handleCronMetrics(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + cronMetricMu.RLock() + history := append([]CronMetric(nil), cronMetricHistory...) + cronMetricMu.RUnlock() + latest := CronMetric{} + if len(history) > 0 { + latest = history[len(history)-1] + } + writeAnyJSON(w, http.StatusOK, map[string]any{ + "count": len(history), + "latest": latest, + "history": history, + }) + case http.MethodPost: + if !allowCronIngest(r) { + writeJSONError(w, http.StatusUnauthorized, "invalid cron token") + return + } + var payload map[string]any + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + writeJSONError(w, http.StatusBadRequest, "invalid json payload") + return + } + sample := normalizeCronMetric(payload) + cronMetricMu.Lock() + cronMetricHistory = append(cronMetricHistory, sample) + if len(cronMetricHistory) > cronMetricMaxKeep { + cronMetricHistory = append([]CronMetric(nil), cronMetricHistory[len(cronMetricHistory)-cronMetricMaxKeep:]...) + } + count := len(cronMetricHistory) + cronMetricMu.Unlock() + writeAnyJSON(w, http.StatusOK, map[string]any{"ok": true, "stored": count, "latest": sample}) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func allowCronIngest(r *http.Request) bool { + expected := strings.TrimSpace(os.Getenv("CRON_TOKEN")) + if expected == "" { + return true + } + return strings.TrimSpace(r.Header.Get("X-Cron-Token")) == expected +} + +func normalizeCronMetric(payload map[string]any) CronMetric { + now := time.Now().UTC().Format(time.RFC3339) + metric := CronMetric{ + Timestamp: now, + Source: textValue(payload, "source", "function", "name", "host", "hostname", "instance"), + Hostname: textValue(payload, "hostname", "host", "node", "instance"), + Status: textValue(payload, "status"), + Notes: textValue(payload, "notes", "note", "message"), + } + if metric.Source == "" { + metric.Source = "cron" + } + + metric.MemoryTotalMB = numberValue(payload, "memory_total_mb", "total_memory_mb", "mem_total_mb", "mem_total") + metric.MemoryFreeMB = numberValue(payload, "memory_free_mb", "free_memory_mb", "mem_free_mb", "free_mb") + metric.MemoryAvailMB = numberValue(payload, "memory_available_mb", "available_memory_mb", "mem_available_mb", "avail_memory_mb") + metric.MemoryUsedMB = numberValue(payload, "memory_used_mb", "used_memory_mb", "mem_used_mb", "used_mb") + metric.MemoryPercent = numberValue(payload, "memory_percent", "mem_percent", "memory_usage_percent") + if metric.MemoryUsedMB == 0 && metric.MemoryTotalMB > 0 && metric.MemoryFreeMB > 0 { + metric.MemoryUsedMB = metric.MemoryTotalMB - metric.MemoryFreeMB + } + if metric.MemoryAvailMB == 0 && metric.MemoryFreeMB > 0 { + metric.MemoryAvailMB = metric.MemoryFreeMB + } + if metric.MemoryPercent == 0 && metric.MemoryTotalMB > 0 && metric.MemoryUsedMB > 0 { + metric.MemoryPercent = (metric.MemoryUsedMB / metric.MemoryTotalMB) * 100 + } + + metric.CpuLoad1m = numberValue(payload, "cpu_load_1m", "loadavg_1m", "load_1m", "load1") + metric.CpuLoad5m = numberValue(payload, "cpu_load_5m", "loadavg_5m", "load_5m", "load5") + metric.CpuLoad15m = numberValue(payload, "cpu_load_15m", "loadavg_15m", "load_15m", "load15") + + metric.DiskTotalGB = numberValue(payload, "disk_total_gb", "total_disk_gb", "disk_total_mb", "disk_total") + metric.DiskFreeGB = numberValue(payload, "disk_free_gb", "free_disk_gb", "disk_free_mb", "disk_free") + metric.DiskUsedGB = numberValue(payload, "disk_used_gb", "used_disk_gb", "disk_used_mb", "disk_used") + if metric.DiskUsedGB == 0 && metric.DiskTotalGB > 0 && metric.DiskFreeGB > 0 { + metric.DiskUsedGB = metric.DiskTotalGB - metric.DiskFreeGB + } + + if uptime := numberValue(payload, "uptime_sec", "uptime", "uptime_seconds"); uptime > 0 { + metric.UptimeSec = uptime + } + if ts := textValue(payload, "timestamp", "ts", "collected_at"); ts != "" { + metric.Timestamp = ts + } + return metric +} + +func textValue(payload map[string]any, keys ...string) string { + for _, key := range keys { + if value, ok := payload[key]; ok { + text := strings.TrimSpace(toString(value)) + if text != "" { + return text + } + } + } + return "" +} + +func numberValue(payload map[string]any, keys ...string) float64 { + for _, key := range keys { + if value, ok := payload[key]; ok { + if number, ok := toFloat64(value); ok { + return number + } + } + } + return 0 +} + +func toString(value any) string { + switch v := value.(type) { + case string: + return v + case []byte: + return string(v) + case float64: + return strconv.FormatFloat(v, 'f', -1, 64) + case float32: + return strconv.FormatFloat(float64(v), 'f', -1, 32) + case int: + return strconv.FormatInt(int64(v), 10) + case int64: + return strconv.FormatInt(v, 10) + case json.Number: + return v.String() + default: + return "" + } +} + +func toFloat64(value any) (float64, bool) { + switch v := value.(type) { + case float64: + return v, true + case float32: + return float64(v), true + case int: + return float64(v), true + case int64: + return float64(v), true + case json.Number: + f, err := v.Float64() + return f, err == nil + case string: + if strings.TrimSpace(v) == "" { + return 0, false + } + n, err := strconv.ParseFloat(strings.TrimSpace(v), 64) + if err == nil { + return n, true + } + return 0, false + default: + return 0, false + } +} diff --git a/console/internal/api/server.go b/console/internal/api/server.go index 8da9770..16c2a46 100644 --- a/console/internal/api/server.go +++ b/console/internal/api/server.go @@ -127,6 +127,15 @@ func (s *Server) RegisterRoutes(mux *http.ServeMux) { // Auth: не требует токена — сам проверяет и возвращает namespace mux.HandleFunc("/console/api/auth", s.handleAuth) + // Cron: публичные маршруты для метрик + cronHandler := ui.CronHandler() + mux.Handle("/cron", cronHandler) + mux.Handle("/cron/", cronHandler) + mux.Handle("/console/cron", cronHandler) + mux.Handle("/console/cron/", cronHandler) + mux.HandleFunc("/cron/api/metrics", s.handleCronMetrics) + mux.HandleFunc("/console/cron/api/metrics", s.handleCronMetrics) + // Приватные: требуют X-Auth-Token / X-Test-Sub auth := s.authMiddleware diff --git a/console/ui/cron.go b/console/ui/cron.go new file mode 100644 index 0000000..3764e24 --- /dev/null +++ b/console/ui/cron.go @@ -0,0 +1,16 @@ +package ui + +import ( + _ "embed" + "net/http" +) + +//go:embed cron.html +var cronPage string + +func CronHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(cronPage)) + }) +} diff --git a/console/ui/cron.html b/console/ui/cron.html new file mode 100644 index 0000000..d7ac743 --- /dev/null +++ b/console/ui/cron.html @@ -0,0 +1,430 @@ + + +
+ + +
+ Python-функция по расписанию отправляет сюда snapshot со свободной памятью, CPU load, диском и uptime.
+ Страница показывает последний снимок и историю замеров в том же стиле, что и основной dashboard: карточки + таблица.
+
+ Примечание: метрики читаются из /proc внутри pod-а и отражают состояние
+ ноды кластера, на которой запущена функция — не ресурсы самого pod-а и не агрегированные показатели кластера.
+
| Время | +Источник | +Хост | +Своб. МБ | +Дост. МБ | +Исп. МБ | +Память % | +ЦП 1м | +ЦП 5м | +ЦП 15м | +Диск своб. | +Аптайм | +Статус | +
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Данных пока нет | ||||||||||||