feat: restore cron page, email in navbar, metrics pipeline v1.3.19
This commit is contained in:
@@ -1,18 +1,51 @@
|
|||||||
# Правила
|
# Правила
|
||||||
|
|
||||||
## ⛔ ОТВЕЧАТЬ КРАТКО — АБСОЛЮТНОЕ ПРАВИЛО
|
## ⛔ ОТВЕЧАТЬ КРАТКО — АБСОЛЮТНОЕ ПРАВИЛО
|
||||||
- Вопрос → короткий ответ → СТОП. Не рассуждать, не исследовать без команды.
|
- Вопрос → короткий ответ → СТОП.
|
||||||
- НЕ делать ничего "попутно" без явной просьбы. Сделал — стоп — ждёшь команды.
|
- Ничего лишнего.
|
||||||
- Без приветствий и лишних рассуждений. Код — только по запросу. Не бормотать.
|
- Код — только по запросу.
|
||||||
|
|
||||||
> Подробные правила: [`.github/pravila.md`](pravila.md)
|
|
||||||
|
|
||||||
1. Не трогать рабочий код без явного указания.
|
1. Не трогать рабочий код без явного указания.
|
||||||
2. Файлы редактировать локально — `~/remote_dev/` = `~/terra/` на ВМ (sshfs), SCP не нужен.
|
|
||||||
Перед любым запуском команд проверять, что локальные файлы и содержимое на ВМ синхронизированы; при сомнении ориентироваться на состояние файлов на ВМ.
|
2. Файлы редактируются локально:
|
||||||
3. Все команды — **только через SSH**, никогда локально:
|
~/fission
|
||||||
```bash
|
|
||||||
|
После ЛЮБЫХ изменений ОБЯЗАТЕЛЬНО синхронизировать на ВМ командой:
|
||||||
|
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 'КОМАНДА'
|
ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10 naeel@5.172.178.213 'КОМАНДА'
|
||||||
```
|
|
||||||
4. **ЗАПРЕЩЕНО** откатывать код, версии, образы и рабочее состояние без явной команды пользователя.
|
- не выполнять команды локально
|
||||||
5. После каждого исправления делать отдельный commit через SSH.
|
- не открывать интерактивные сессии
|
||||||
|
- не делать цепочки без необходимости
|
||||||
|
|
||||||
|
4. Перед запуском команд ОБЯЗАТЕЛЬНО убедиться, что синхронизация выполнена.
|
||||||
|
|
||||||
|
5. ЗАПРЕЩЕНО:
|
||||||
|
- откатывать код
|
||||||
|
- менять версии
|
||||||
|
- ломать рабочее состояние
|
||||||
|
|
||||||
|
6. После каждого исправления:
|
||||||
|
- синхронизация (rsync)
|
||||||
|
- затем commit через SSH
|
||||||
|
|
||||||
|
## ⛔⛔⛔ ДЕЛАТЬ ТОЛЬКО ЧТО ПРЯМО ПРИКАЗАНО
|
||||||
|
|
||||||
|
**АБСОЛЮТНЫЙ ЗАПРЕТ на додумывание:**
|
||||||
|
- Не расширять масштаб работы
|
||||||
|
- Не выполнять "логичные следующие шаги"
|
||||||
|
- Не инициировать дополнительные операции
|
||||||
|
- Не делать ничего кроме того что сказано
|
||||||
|
|
||||||
|
**Пример (2026-05-01):**
|
||||||
|
- Приказано: "собери"
|
||||||
|
- Сделано: ✓ собрал образы v1.3.17 и v0.1.2
|
||||||
|
- СТОП — жду команды дальше
|
||||||
|
- **ЗАПРЕЩЕНО:** обновлять манифесты, заливать образы, применять на кластер, запускать тесты
|
||||||
|
|
||||||
|
**Исключение:** только если приказ явно включает цепочку ("собери И залей И тесты")
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ spec:
|
|||||||
serviceAccountName: fission-console
|
serviceAccountName: fission-console
|
||||||
containers:
|
containers:
|
||||||
- name: console
|
- name: console
|
||||||
image: naeel/fission-console:v1.3.12
|
image: naeel/fission-console:v1.3.19
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 8090
|
- containerPort: 8090
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -127,6 +127,15 @@ func (s *Server) RegisterRoutes(mux *http.ServeMux) {
|
|||||||
// Auth: не требует токена — сам проверяет и возвращает namespace
|
// Auth: не требует токена — сам проверяет и возвращает namespace
|
||||||
mux.HandleFunc("/console/api/auth", s.handleAuth)
|
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
|
// Приватные: требуют X-Auth-Token / X-Test-Sub
|
||||||
auth := s.authMiddleware
|
auth := s.authMiddleware
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,430 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>NUBES Cron</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg-page: #001120;
|
||||||
|
--bg-surface: #001929;
|
||||||
|
--bg-navbar: #001c34;
|
||||||
|
--border: #0b2d50;
|
||||||
|
--accent: #1a7fd4;
|
||||||
|
--text-primary: #e2ecf6;
|
||||||
|
--text-secondary: #6b8eaa;
|
||||||
|
--good: #56d364;
|
||||||
|
--warn: #e3b341;
|
||||||
|
--bad: #f85149;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
||||||
|
background: linear-gradient(180deg, #001120 0%, #061424 100%);
|
||||||
|
color: var(--text-primary);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
a { color: inherit; text-decoration: none; }
|
||||||
|
.navbar {
|
||||||
|
height: 56px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 20px;
|
||||||
|
background: var(--bg-navbar);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.brand { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.brand-mark {
|
||||||
|
width: 30px; height: 30px; border-radius: 8px;
|
||||||
|
background: linear-gradient(135deg, #009dff 0%, #0055d4 100%);
|
||||||
|
color: #fff; display: grid; place-items: center;
|
||||||
|
font-size: 14px; font-weight: 800; letter-spacing: .02em;
|
||||||
|
box-shadow: 0 6px 18px rgba(0, 125, 255, .35);
|
||||||
|
}
|
||||||
|
.brand-text { display: flex; flex-direction: column; line-height: 1.08; }
|
||||||
|
.brand-text .nubes { font-size: 12px; letter-spacing: .22em; font-weight: 800; color: #8bc7ff; }
|
||||||
|
.brand-text .product { font-size: 13px; font-weight: 700; letter-spacing: .04em; }
|
||||||
|
.btn {
|
||||||
|
background: var(--accent); color: #fff; border: 0; border-radius: 6px;
|
||||||
|
padding: 8px 12px; cursor: pointer; font-size: 13px; line-height: 1.2;
|
||||||
|
}
|
||||||
|
.btn.ghost { background: #0c2b49; }
|
||||||
|
.btn:hover { opacity: .95; }
|
||||||
|
.wrap { max-width: 1320px; margin: 0 auto; padding: 20px; }
|
||||||
|
.hero {
|
||||||
|
background: linear-gradient(180deg, #001929 0%, #001622 100%);
|
||||||
|
border: 1px solid var(--border); border-radius: 14px; padding: 18px;
|
||||||
|
box-shadow: 0 12px 34px rgba(0, 0, 0, .18);
|
||||||
|
}
|
||||||
|
.hero-top { display: flex; justify-content: space-between; gap: 16px; align-items: flex-start; flex-wrap: wrap; }
|
||||||
|
.hero h1 { font-size: 28px; margin-bottom: 6px; }
|
||||||
|
.hero p { color: var(--text-secondary); max-width: 920px; line-height: 1.5; font-size: 14px; }
|
||||||
|
.hero .actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.clock {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 14px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(12, 43, 73, .72);
|
||||||
|
border: 1px solid #1d486d;
|
||||||
|
color: #d7e7f7;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: .02em;
|
||||||
|
}
|
||||||
|
.clock small {
|
||||||
|
display: block;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: .08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background: var(--bg-surface); border: 1px solid var(--border); border-radius: 12px; padding: 14px 16px;
|
||||||
|
}
|
||||||
|
.k { color: var(--text-secondary); font-size: 12px; text-transform: uppercase; letter-spacing: .06em; }
|
||||||
|
.v { font-size: 24px; font-weight: 800; margin-top: 6px; }
|
||||||
|
.s { font-size: 12px; color: var(--text-secondary); margin-top: 4px; min-height: 16px; }
|
||||||
|
.panel {
|
||||||
|
margin-top: 14px; background: var(--bg-surface); border: 1px solid var(--border); border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.panel-head {
|
||||||
|
display: flex; justify-content: space-between; gap: 12px; align-items: center; flex-wrap: wrap;
|
||||||
|
padding: 14px 16px; border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.panel-head h2 { font-size: 16px; }
|
||||||
|
.meta { color: var(--text-secondary); font-size: 12px; }
|
||||||
|
table { width: 100%; border-collapse: collapse; min-width: 1220px; }
|
||||||
|
th, td { padding: 9px 11px; border-bottom: 1px solid #0b2a48; text-align: left; }
|
||||||
|
th {
|
||||||
|
color: var(--text-secondary); font-size: 11px; text-transform: uppercase; letter-spacing: .05em; white-space: nowrap;
|
||||||
|
}
|
||||||
|
td { font-size: 13px; vertical-align: middle; }
|
||||||
|
tbody tr:hover { background: rgba(26, 127, 212, .08); }
|
||||||
|
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
|
||||||
|
.status-ok { color: var(--good); }
|
||||||
|
.status-warn { color: var(--warn); }
|
||||||
|
.status-bad { color: var(--bad); }
|
||||||
|
.empty { color: var(--text-secondary); padding: 24px 12px; text-align: center; }
|
||||||
|
.pagination {
|
||||||
|
display: flex; align-items: center; gap: 6px; flex-wrap: wrap;
|
||||||
|
padding: 12px 16px; border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.page-btn {
|
||||||
|
min-width: 32px; height: 28px; padding: 0 8px; border-radius: 6px;
|
||||||
|
background: #0c2b49; border: 1px solid #1d486d; color: #d7e7f7;
|
||||||
|
font-size: 12px; cursor: pointer; display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.page-btn:hover { background: #153d68; }
|
||||||
|
.page-btn.active { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||||
|
.page-btn:disabled { opacity: .35; cursor: default; }
|
||||||
|
.page-info { color: var(--text-secondary); font-size: 12px; margin-left: 6px; }
|
||||||
|
.footer {
|
||||||
|
padding-top: 12px; color: var(--text-secondary); font-size: 12px; line-height: 1.5;
|
||||||
|
}
|
||||||
|
.badge {
|
||||||
|
display: inline-block; padding: 2px 7px; border-radius: 999px; background: #0c2b49; border: 1px solid #1d486d;
|
||||||
|
color: #d7e7f7; font-size: 11px;
|
||||||
|
}
|
||||||
|
@media (max-width: 820px) {
|
||||||
|
.hero h1 { font-size: 22px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="navbar">
|
||||||
|
<div class="brand">
|
||||||
|
<div class="brand-mark">N</div>
|
||||||
|
<div class="brand-text">
|
||||||
|
<div class="nubes">NUBES</div>
|
||||||
|
<div class="product">МЕТРИКИ CRON</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn ghost" id="btn-refresh">Обновить</button>
|
||||||
|
<a class="btn ghost" href="/console/">Консоль</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="wrap">
|
||||||
|
<section class="hero">
|
||||||
|
<div class="hero-top">
|
||||||
|
<div>
|
||||||
|
<h1>Снимки состояния по cron</h1>
|
||||||
|
<p>
|
||||||
|
Python-функция по расписанию отправляет сюда snapshot со свободной памятью, CPU load, диском и uptime.
|
||||||
|
Страница показывает последний снимок и историю замеров в том же стиле, что и основной dashboard: карточки + таблица.
|
||||||
|
<br><br>
|
||||||
|
<strong>Примечание:</strong> метрики читаются из <span class="mono">/proc</span> внутри pod-а и отражают состояние
|
||||||
|
<em>ноды кластера</em>, на которой запущена функция — не ресурсы самого pod-а и не агрегированные показатели кластера.
|
||||||
|
</p>
|
||||||
|
<div class="clock">
|
||||||
|
<div>
|
||||||
|
<small>Current time</small>
|
||||||
|
<span id="current-time">--:--:--</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<span class="badge" id="points-badge">0 points</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid" id="summary"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<h2>История</h2>
|
||||||
|
<div class="meta" id="last-updated">Ожидание первых данных…</div>
|
||||||
|
</div>
|
||||||
|
<div class="meta mono" id="latest-source"></div>
|
||||||
|
</div>
|
||||||
|
<div style="overflow-x:auto;">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Время</th>
|
||||||
|
<th>Источник</th>
|
||||||
|
<th>Хост</th>
|
||||||
|
<th>Своб. МБ</th>
|
||||||
|
<th>Дост. МБ</th>
|
||||||
|
<th>Исп. МБ</th>
|
||||||
|
<th>Память %</th>
|
||||||
|
<th>ЦП 1м</th>
|
||||||
|
<th>ЦП 5м</th>
|
||||||
|
<th>ЦП 15м</th>
|
||||||
|
<th>Диск своб.</th>
|
||||||
|
<th>Аптайм</th>
|
||||||
|
<th>Статус</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="history-body">
|
||||||
|
<tr><td class="empty" colspan="13">Данных пока нет</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div id="pagination" class="pagination"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
Endpoint для cron-функции: <span class="mono">POST /cron/api/metrics</span>.<br>
|
||||||
|
Пример полезной нагрузки: <span class="mono">{"source":"my-fn","memory_free_mb":1234,"cpu_load_1m":0.12}</span>.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const API = '/cron/api/metrics';
|
||||||
|
const summaryEl = document.getElementById('summary');
|
||||||
|
const historyBody = document.getElementById('history-body');
|
||||||
|
const lastUpdatedEl = document.getElementById('last-updated');
|
||||||
|
const latestSourceEl = document.getElementById('latest-source');
|
||||||
|
const pointsBadge = document.getElementById('points-badge');
|
||||||
|
const currentTimeEl = document.getElementById('current-time');
|
||||||
|
|
||||||
|
function num(v) {
|
||||||
|
if (v === null || v === undefined || v === '') return null;
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtNum(v, d = 1, suffix = '') {
|
||||||
|
const n = num(v);
|
||||||
|
if (n === null) return '—';
|
||||||
|
return n.toFixed(d) + suffix;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTime(v) {
|
||||||
|
if (!v) return '—';
|
||||||
|
const d = new Date(v);
|
||||||
|
if (Number.isNaN(d.getTime())) return String(v);
|
||||||
|
return new Intl.DateTimeFormat('ru-RU', {
|
||||||
|
year: '2-digit', month: '2-digit', day: '2-digit',
|
||||||
|
hour: '2-digit', minute: '2-digit', second: '2-digit'
|
||||||
|
}).format(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateClock() {
|
||||||
|
currentTimeEl.textContent = new Intl.DateTimeFormat('ru-RU', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric'
|
||||||
|
}).format(new Date());
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtUptime(v) {
|
||||||
|
const sec = num(v);
|
||||||
|
if (sec === null) return '—';
|
||||||
|
const days = Math.floor(sec / 86400);
|
||||||
|
const hours = Math.floor((sec % 86400) / 3600);
|
||||||
|
const mins = Math.floor((sec % 3600) / 60);
|
||||||
|
if (days > 0) return `${days}д ${hours}ч ${mins}м`;
|
||||||
|
if (hours > 0) return `${hours}ч ${mins}м`;
|
||||||
|
return `${Math.floor(sec)}с`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cls(status) {
|
||||||
|
const s = String(status || '').toLowerCase();
|
||||||
|
if (s.includes('err') || s.includes('fail') || s.includes('bad')) return 'status-bad';
|
||||||
|
if (s.includes('warn') || s.includes('degrad') || s.includes('idle')) return 'status-warn';
|
||||||
|
return 'status-ok';
|
||||||
|
}
|
||||||
|
|
||||||
|
function card(label, value, subtitle) {
|
||||||
|
return `
|
||||||
|
<div class="card">
|
||||||
|
<div class="k">${label}</div>
|
||||||
|
<div class="v">${value}</div>
|
||||||
|
<div class="s">${subtitle || ''}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSummary(latest, count) {
|
||||||
|
if (!latest) {
|
||||||
|
summaryEl.innerHTML = [
|
||||||
|
card('Снимки', '0', 'Ожидание cron-запросов'),
|
||||||
|
card('Своб. память', '—', 'МБ'),
|
||||||
|
card('Дост. память', '—', 'МБ'),
|
||||||
|
card('Исп. память', '—', 'МБ'),
|
||||||
|
card('Нагрузка ЦП', '—', '1м / 5м / 15м'),
|
||||||
|
card('Диск своб.', '—', 'ГБ')
|
||||||
|
].join('');
|
||||||
|
pointsBadge.textContent = '0 снимков';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
summaryEl.innerHTML = [
|
||||||
|
card('Снимки', String(count), `Источник: ${latest.source || 'cron'}`),
|
||||||
|
card('Своб. память', fmtNum(latest.memory_free_mb, 1, ' МБ'), latest.hostname || '—'),
|
||||||
|
card('Дост. память', fmtNum(latest.memory_available_mb, 1, ' МБ'), 'Доступная RAM'),
|
||||||
|
card('Исп. память', fmtNum(latest.memory_used_mb, 1, ' МБ'), `Память ${fmtNum(latest.memory_percent, 1, '%')}`),
|
||||||
|
card('Нагрузка ЦП', [fmtNum(latest.cpu_load_1m, 2), fmtNum(latest.cpu_load_5m, 2), fmtNum(latest.cpu_load_15m, 2)].join(' / '), '1м / 5м / 15м'),
|
||||||
|
card('Диск своб.', fmtNum(latest.disk_free_gb, 1, ' ГБ'), `Аптайм ${fmtUptime(latest.uptime_sec)}`)
|
||||||
|
].join('');
|
||||||
|
pointsBadge.textContent = `${count} снимк${count === 1 ? '' : 'ов'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE_SIZE = 10;
|
||||||
|
let allHistory = [];
|
||||||
|
let currentPage = 1;
|
||||||
|
|
||||||
|
function renderPage(page) {
|
||||||
|
const total = allHistory.length;
|
||||||
|
const pages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||||
|
currentPage = Math.min(Math.max(1, page), pages);
|
||||||
|
const pg = document.getElementById('pagination');
|
||||||
|
|
||||||
|
if (!total) {
|
||||||
|
historyBody.innerHTML = '<tr><td class="empty" colspan="13">Данных пока нет</td></tr>';
|
||||||
|
pg.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = (currentPage - 1) * PAGE_SIZE;
|
||||||
|
const slice = allHistory.slice(start, start + PAGE_SIZE);
|
||||||
|
|
||||||
|
historyBody.innerHTML = slice.map(function (item) {
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td class="mono">${fmtTime(item.timestamp)}</td>
|
||||||
|
<td>${item.source || '—'}</td>
|
||||||
|
<td>${item.hostname || '—'}</td>
|
||||||
|
<td class="mono">${fmtNum(item.memory_free_mb, 1)}</td>
|
||||||
|
<td class="mono">${fmtNum(item.memory_available_mb, 1)}</td>
|
||||||
|
<td class="mono">${fmtNum(item.memory_used_mb, 1)}</td>
|
||||||
|
<td class="mono">${fmtNum(item.memory_percent, 1, '%')}</td>
|
||||||
|
<td class="mono">${fmtNum(item.cpu_load_1m, 2)}</td>
|
||||||
|
<td class="mono">${fmtNum(item.cpu_load_5m, 2)}</td>
|
||||||
|
<td class="mono">${fmtNum(item.cpu_load_15m, 2)}</td>
|
||||||
|
<td class="mono">${fmtNum(item.disk_free_gb, 1, ' ГБ')}</td>
|
||||||
|
<td class="mono">${fmtUptime(item.uptime_sec)}</td>
|
||||||
|
<td class="${cls(item.status)}">${item.status || 'ok'}</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
if (pages <= 1) { pg.innerHTML = ''; return; }
|
||||||
|
|
||||||
|
let btns = `<button class="page-btn" id="pg-prev" ${currentPage === 1 ? 'disabled' : ''}>←</button>`;
|
||||||
|
for (let i = 1; i <= pages; i++) {
|
||||||
|
btns += `<button class="page-btn ${i === currentPage ? 'active' : ''}" data-page="${i}">${i}</button>`;
|
||||||
|
}
|
||||||
|
btns += `<button class="page-btn" id="pg-next" ${currentPage === pages ? 'disabled' : ''}>→</button>`;
|
||||||
|
btns += `<span class="page-info">стр. ${currentPage} из ${pages} · всего ${total}</span>`;
|
||||||
|
pg.innerHTML = btns;
|
||||||
|
|
||||||
|
pg.querySelector('#pg-prev').addEventListener('click', function () { renderPage(currentPage - 1); });
|
||||||
|
pg.querySelector('#pg-next').addEventListener('click', function () { renderPage(currentPage + 1); });
|
||||||
|
pg.querySelectorAll('[data-page]').forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function () { renderPage(Number(btn.dataset.page)); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTable(history) {
|
||||||
|
allHistory = history.slice().reverse();
|
||||||
|
renderPage(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateMeta(history) {
|
||||||
|
if (!history.length) {
|
||||||
|
lastUpdatedEl.textContent = 'Ожидание первых данных…';
|
||||||
|
latestSourceEl.textContent = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const latest = history[history.length - 1];
|
||||||
|
lastUpdatedEl.textContent = `Последний снимок: ${fmtTime(latest.timestamp)}${latest.hostname ? ' · ' + latest.hostname : ''}`;
|
||||||
|
latestSourceEl.textContent = latest.source ? `source=${latest.source}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMetrics() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(API, { headers: { 'Accept': 'application/json' } });
|
||||||
|
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||||
|
const data = await res.json();
|
||||||
|
const history = Array.isArray(data.history) ? data.history : [];
|
||||||
|
const latest = data.latest || (history.length ? history[history.length - 1] : null);
|
||||||
|
renderSummary(latest, history.length || (latest ? 1 : 0));
|
||||||
|
renderTable(history);
|
||||||
|
updateMeta(history);
|
||||||
|
} catch (err) {
|
||||||
|
summaryEl.innerHTML = [
|
||||||
|
card('Снимки', '—', 'Ошибка загрузки'),
|
||||||
|
card('Своб. память', '—', err.message),
|
||||||
|
card('Дост. память', '—', 'Проверить backend'),
|
||||||
|
card('Исп. память', '—', 'Проверить cron-функцию'),
|
||||||
|
card('Нагрузка ЦП', '—', 'Проверить backend'),
|
||||||
|
card('Диск своб.', '—', 'Проверить приём данных')
|
||||||
|
].join('');
|
||||||
|
historyBody.innerHTML = `<tr><td class="empty" colspan="13">Ошибка загрузки: ${err.message}</td></tr>`;
|
||||||
|
lastUpdatedEl.textContent = 'Ошибка обновления';
|
||||||
|
latestSourceEl.textContent = '';
|
||||||
|
pointsBadge.textContent = '0 снимков';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('btn-refresh').addEventListener('click', loadMetrics);
|
||||||
|
updateClock();
|
||||||
|
setInterval(updateClock, 1000);
|
||||||
|
loadMetrics();
|
||||||
|
setInterval(loadMetrics, 30000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+1
-1
@@ -5,7 +5,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed index.html
|
//go:embed index.html cron.html
|
||||||
var content embed.FS
|
var content embed.FS
|
||||||
|
|
||||||
func Handler() http.Handler {
|
func Handler() http.Handler {
|
||||||
|
|||||||
+32
-3
@@ -406,7 +406,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="navbar">
|
<div class="navbar">
|
||||||
<div class="brand-mark">N</div>
|
<div class="brand-mark" id="user-avatar">N</div>
|
||||||
<div class="brand-text">
|
<div class="brand-text">
|
||||||
<div class="nubes">NUBES</div>
|
<div class="nubes">NUBES</div>
|
||||||
<div class="product">FISSION CONSOLE</div>
|
<div class="product">FISSION CONSOLE</div>
|
||||||
@@ -416,7 +416,6 @@
|
|||||||
<button class="btn ghost" onclick="reloadAll()">Refresh</button>
|
<button class="btn ghost" onclick="reloadAll()">Refresh</button>
|
||||||
<button class="btn" onclick="openCreate()">+ Создать функцию</button>
|
<button class="btn" onclick="openCreate()">+ Создать функцию</button>
|
||||||
<button class="btn ghost" onclick="openHelp()">Help</button>
|
<button class="btn ghost" onclick="openHelp()">Help</button>
|
||||||
<a class="btn ghost" href="/cron/" target="_blank" rel="noopener">Cron</a>
|
|
||||||
<button class="btn ghost" onclick="doLogout()" style="margin-left:8px;">Выход</button>
|
<button class="btn ghost" onclick="doLogout()" style="margin-left:8px;">Выход</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1246,6 +1245,7 @@
|
|||||||
}
|
}
|
||||||
localStorage.setItem('auth_token', token);
|
localStorage.setItem('auth_token', token);
|
||||||
localStorage.setItem('auth_env', env);
|
localStorage.setItem('auth_env', env);
|
||||||
|
setUserAvatar(token);
|
||||||
hideLoginOverlay();
|
hideLoginOverlay();
|
||||||
// Проверяем статус NS — если не ready, показываем init overlay
|
// Проверяем статус NS — если не ready, показываем init overlay
|
||||||
try {
|
try {
|
||||||
@@ -1265,10 +1265,37 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function jwtEmail(token) {
|
||||||
|
try {
|
||||||
|
var payload = JSON.parse(atob(token.split('.')[1].replace(/-/g,'+').replace(/_/g,'/')));
|
||||||
|
return payload.email || payload.sub || null;
|
||||||
|
} catch (_) { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function setUserAvatar(token) {
|
||||||
|
var el = document.getElementById('user-avatar');
|
||||||
|
if (!el) return;
|
||||||
|
var email = token ? jwtEmail(token) : null;
|
||||||
|
if (email) {
|
||||||
|
el.textContent = email;
|
||||||
|
el.title = email;
|
||||||
|
el.style.fontSize = '0.7rem';
|
||||||
|
el.style.minWidth = '60px';
|
||||||
|
el.style.padding = '0 8px';
|
||||||
|
} else {
|
||||||
|
el.textContent = 'N';
|
||||||
|
el.title = '';
|
||||||
|
el.style.fontSize = '';
|
||||||
|
el.style.minWidth = '';
|
||||||
|
el.style.padding = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function doLogout() {
|
function doLogout() {
|
||||||
localStorage.removeItem('auth_token');
|
localStorage.removeItem('auth_token');
|
||||||
localStorage.removeItem('auth_env');
|
localStorage.removeItem('auth_env');
|
||||||
try { document.getElementById('l-token').value = ''; } catch (_) { }
|
try { document.getElementById('l-token').value = ''; } catch (_) { }
|
||||||
|
setUserAvatar(null);
|
||||||
showLoginOverlay();
|
showLoginOverlay();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1480,10 +1507,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function checkAuth() {
|
function checkAuth() {
|
||||||
if (!localStorage.getItem('auth_token')) {
|
var storedToken = localStorage.getItem('auth_token');
|
||||||
|
if (!storedToken) {
|
||||||
showLoginOverlay();
|
showLoginOverlay();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setUserAvatar(storedToken);
|
||||||
hideLoginOverlay();
|
hideLoginOverlay();
|
||||||
reloadAll();
|
reloadAll();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# План интеграции загрузки функций из архивов в UI Fission Console
|
||||||
|
|
||||||
|
## Цель
|
||||||
|
Добавить в web-интерфейс Fission Console возможность загружать функцию в виде архива (zip/tar), аналогично `fission function create --deploy ...`.
|
||||||
|
|
||||||
|
## Основные шаги
|
||||||
|
1. UI: добавить компонент для выбора и загрузки архива (drag&drop или file input).
|
||||||
|
2. Backend: реализовать обработку архива, валидацию структуры, запуск линтеров и (опционально) AI-проверку.
|
||||||
|
3. Интеграция с Fission API: отправка архива через REST (multipart/form-data) для создания/обновления функции.
|
||||||
|
4. UI: отображение статуса загрузки, ошибок, логов билда.
|
||||||
|
|
||||||
|
## Валидация архива
|
||||||
|
- Проверка наличия entrypoint-файла (main.py/handler.js и т.д.)
|
||||||
|
- Проверка структуры (отсутствие мусора, секретов)
|
||||||
|
- Линтеры (flake8/pylint/go vet и т.д.)
|
||||||
|
- (Опционально) AI-проверка через облачный LLM
|
||||||
|
|
||||||
|
## Безопасность
|
||||||
|
- Ограничить размер архива
|
||||||
|
- Фильтрация запрещённых файлов
|
||||||
|
- Логирование действий
|
||||||
|
|
||||||
|
## Синхронизация с Terraform/CLI
|
||||||
|
- Архивы, деплоенные через UI, CLI, Terraform — одинакового формата
|
||||||
|
- UI всегда отображает актуальный список функций из Fission API
|
||||||
|
|
||||||
|
## Возможные сложности
|
||||||
|
- Ошибки структуры архива (нет entrypoint)
|
||||||
|
- Долгая сборка/деплой больших архивов
|
||||||
|
- Drift при одновременном управлении через Terraform и UI
|
||||||
|
|
||||||
|
## Следующие шаги
|
||||||
|
- Реализовать UI-компонент для загрузки архива
|
||||||
|
- Прототип backend-валидации
|
||||||
|
- Интеграция с Fission API
|
||||||
|
- Документировать пайплайн и типовые ошибки
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import os
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
import json
|
||||||
|
|
||||||
|
COLLECTOR_URL = "http://metrics-collector.fission.svc.cluster.local:8091/metrics"
|
||||||
|
|
||||||
|
def main():
|
||||||
|
payload = {
|
||||||
|
"source": "fission-cron-metrics",
|
||||||
|
"hostname": os.uname().nodename,
|
||||||
|
"status": "ok",
|
||||||
|
}
|
||||||
|
|
||||||
|
# CPU load
|
||||||
|
try:
|
||||||
|
load = os.getloadavg()
|
||||||
|
payload["cpu_load_1m"] = load[0]
|
||||||
|
payload["cpu_load_5m"] = load[1]
|
||||||
|
payload["cpu_load_15m"] = load[2]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Memory
|
||||||
|
try:
|
||||||
|
with open("/proc/meminfo") as f:
|
||||||
|
meminfo = {}
|
||||||
|
for line in f:
|
||||||
|
k, v = line.split(":")
|
||||||
|
meminfo[k.strip()] = int(v.split()[0])
|
||||||
|
total = meminfo.get("MemTotal", 0) / 1024
|
||||||
|
free = meminfo.get("MemFree", 0) / 1024
|
||||||
|
avail = meminfo.get("MemAvailable", 0) / 1024
|
||||||
|
used = total - free
|
||||||
|
payload["memory_total_mb"] = round(total, 1)
|
||||||
|
payload["memory_free_mb"] = round(free, 1)
|
||||||
|
payload["memory_available_mb"] = round(avail, 1)
|
||||||
|
payload["memory_used_mb"] = round(used, 1)
|
||||||
|
payload["memory_percent"] = round(used / total * 100, 1) if total else 0
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Disk
|
||||||
|
try:
|
||||||
|
st = os.statvfs("/")
|
||||||
|
total_gb = st.f_blocks * st.f_frsize / 1024**3
|
||||||
|
free_gb = st.f_bfree * st.f_frsize / 1024**3
|
||||||
|
used_gb = total_gb - free_gb
|
||||||
|
payload["disk_total_gb"] = round(total_gb, 1)
|
||||||
|
payload["disk_free_gb"] = round(free_gb, 1)
|
||||||
|
payload["disk_used_gb"] = round(used_gb, 1)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Uptime
|
||||||
|
try:
|
||||||
|
with open("/proc/uptime") as f:
|
||||||
|
payload["uptime_sec"] = float(f.read().split()[0])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
data = json.dumps(payload).encode()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
COLLECTOR_URL,
|
||||||
|
data=data,
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||||
|
return {"ok": True, "status": resp.status}
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
return {"ok": False, "error": str(e)}
|
||||||
@@ -158,21 +158,6 @@ sub main {
|
|||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
write_go() {
|
|
||||||
local dir=$1
|
|
||||||
mkdir -p "${dir}"
|
|
||||||
cat > "${dir}/handler.go" <<'EOF'
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
)
|
|
||||||
|
|
||||||
func Handler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(map[string]string{"msg": "hello-go"})
|
|
||||||
}
|
|
||||||
EOF
|
EOF
|
||||||
cat > "${dir}/go.mod" <<'EOF'
|
cat > "${dir}/go.mod" <<'EOF'
|
||||||
module fn
|
module fn
|
||||||
@@ -260,41 +245,6 @@ kube_exists "environments.fission.io" "simple-python-env" \
|
|||||||
tf_destroy "${D}" | grep -q "Destroy complete" && pass "T02: destroy OK" || fail "T02: destroy FAILED"
|
tf_destroy "${D}" | grep -q "Destroy complete" && pass "T02: destroy OK" || fail "T02: destroy FAILED"
|
||||||
kube_gone "functions.fission.io" "${T02}" && pass "T02: Function удалена" || fail "T02: Function осталась"
|
kube_gone "functions.fission.io" "${T02}" && pass "T02: Function удалена" || fail "T02: Function осталась"
|
||||||
|
|
||||||
# ════════════════════════════════════════════════════════════════════════
|
|
||||||
echo ""
|
|
||||||
echo ">>> T03: go — source pipeline, create с URL"
|
|
||||||
# ════════════════════════════════════════════════════════════════════════
|
|
||||||
T03="${RUN_ID}-go-basic"
|
|
||||||
D=$(setup_workdir "t03")
|
|
||||||
write_provider "${D}"
|
|
||||||
write_go "${D}/code"
|
|
||||||
cat >> "${D}/main.tf" <<EOF
|
|
||||||
resource "fission_simple_function" "fn" {
|
|
||||||
name = "${T03}"
|
|
||||||
runtime = "go"
|
|
||||||
code_dir = "\${path.module}/code"
|
|
||||||
url = "/test/${T03}"
|
|
||||||
methods = ["GET", "POST"]
|
|
||||||
}
|
|
||||||
EOF
|
|
||||||
|
|
||||||
tf_apply "${D}" | grep -q "Apply complete" \
|
|
||||||
&& pass "T03: go apply OK" || fail "T03: go apply FAILED"
|
|
||||||
kube_exists "functions.fission.io" "${T03}" && pass "T03: Function создана" || fail "T03: Function не найдена"
|
|
||||||
kube_exists "packages.fission.io" "${T03}-pkg" && pass "T03: Package создан" || fail "T03: Package не найден"
|
|
||||||
kube_exists "httptriggers.fission.io" "${T03}-trigger" && pass "T03: Trigger создан" || fail "T03: Trigger не найден"
|
|
||||||
kube_exists "environments.fission.io" "simple-go-env" && pass "T03: simple-go-env OK" || fail "T03: simple-go-env не найден"
|
|
||||||
|
|
||||||
# Проверяем что Package имеет source (не deployment)
|
|
||||||
PKG_SOURCE=$(kubectl get packages.fission.io -n "${NAMESPACE}" "${T03}-pkg" \
|
|
||||||
-o jsonpath='{.spec.source.type}' 2>/dev/null || echo "")
|
|
||||||
[ "${PKG_SOURCE}" = "literal" ] \
|
|
||||||
&& pass "T03: Package.spec.source.type=literal (source deploy)" \
|
|
||||||
|| fail "T03: Package.spec.source.type='${PKG_SOURCE}' (ожидалось literal)"
|
|
||||||
|
|
||||||
tf_destroy "${D}" | grep -q "Destroy complete" && pass "T03: destroy OK" || fail "T03: destroy FAILED"
|
|
||||||
kube_gone "functions.fission.io" "${T03}" && pass "T03: Function удалена" || fail "T03: Function осталась"
|
|
||||||
|
|
||||||
# ════════════════════════════════════════════════════════════════════════
|
# ════════════════════════════════════════════════════════════════════════
|
||||||
echo ""
|
echo ""
|
||||||
echo ">>> T04: ruby — create с URL"
|
echo ">>> T04: ruby — create с URL"
|
||||||
|
|||||||
-34
@@ -10,7 +10,6 @@ RUN_ID=$(echo "$RUN_ID" | sha256sum 2>/dev/null | head -c 8 || echo "${RUN_ID}"
|
|||||||
|
|
||||||
cleanup_namespaces() {
|
cleanup_namespaces() {
|
||||||
local subs=()
|
local subs=()
|
||||||
subs+=("${GO_NS}")
|
|
||||||
subs+=("alice@test.local" "routetest-alice@test.local" "routetest-bob@test.local")
|
subs+=("alice@test.local" "routetest-alice@test.local" "routetest-bob@test.local")
|
||||||
subs+=("val-user@test.local" "dup-user@test.local" "upd-user@test.local" "get-user@test.local")
|
subs+=("val-user@test.local" "dup-user@test.local" "upd-user@test.local" "get-user@test.local")
|
||||||
subs+=("ns-u1@test.local" "ns-u2@test.local" "alice-del@test.local" "carol-del@test.local")
|
subs+=("ns-u1@test.local" "ns-u2@test.local" "alice-del@test.local" "carol-del@test.local")
|
||||||
@@ -38,12 +37,6 @@ cleanup_namespaces() {
|
|||||||
}
|
}
|
||||||
trap cleanup_namespaces EXIT
|
trap cleanup_namespaces EXIT
|
||||||
|
|
||||||
# GO_NS фиксированный — namespace создаётся один раз, Fission не рестартует при повторных прогонах.
|
|
||||||
# Первый прогон занимает ~5 мин (rolling restart Fission), последующие — мгновенно.
|
|
||||||
GO_NS="go-livetest@test.local"
|
|
||||||
# Триггерим ensureUserNamespace заранее — Fission начнёт регистрировать NS пока идут остальные тесты.
|
|
||||||
curl -s -o /dev/null -X GET ${BASE}/functions -H "X-Test-Sub: ${GO_NS}" || true
|
|
||||||
|
|
||||||
pass() { PASS=$((PASS+1)); echo " PASS: $1"; }
|
pass() { PASS=$((PASS+1)); echo " PASS: $1"; }
|
||||||
fail() { FAIL=$((FAIL+1)); echo " FAIL: $1"; }
|
fail() { FAIL=$((FAIL+1)); echo " FAIL: $1"; }
|
||||||
check_http() { local got=$1 want=$2 label=$3; [ "$got" = "$want" ] && pass "$label (HTTP $got)" || fail "$label --- got HTTP $got, want $want"; }
|
check_http() { local got=$1 want=$2 label=$3; [ "$got" = "$want" ] && pass "$label (HTTP $got)" || fail "$label --- got HTTP $got, want $want"; }
|
||||||
@@ -783,33 +776,6 @@ check_http "$CODE" "400" "Невалидный язык cobol → 400"
|
|||||||
|
|
||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
echo ""
|
echo ""
|
||||||
echo ">>> БЛОК AE: Go — создание и вызов"
|
|
||||||
|
|
||||||
# Go: namespace GO_NS уже прогрет в начале теста (curl в самом верху).
|
|
||||||
# При первом прогоне Fission делает rolling restart (~5 мин) — retry до 600s покрывает это.
|
|
||||||
# При последующих прогонах NS уже зарегистрирован → invoke занимает ~10-15s.
|
|
||||||
GO_CODE=$(python3 -c "import json; print(json.dumps('package main\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\n)\n\nfunc Handler(w http.ResponseWriter, r *http.Request) {\n\tfmt.Fprintf(w, \"go-ok\")\n}'))")
|
|
||||||
# Удаляем старую функцию если осталась от прошлого прогона (игнорируем ошибку)
|
|
||||||
curl -s -o /dev/null -X DELETE ${BASE}/functions/go-fn -H "X-Test-Sub: ${GO_NS}" || true
|
|
||||||
R=$(curl -s -w "\n%{http_code}" -X POST ${BASE}/functions \
|
|
||||||
-H "X-Test-Sub: ${GO_NS}" -H "Content-Type: application/json" \
|
|
||||||
-d "{\"name\":\"go-fn\",\"language\":\"go\",\"code\":${GO_CODE},\"ttl\":\"30m\"}")
|
|
||||||
CODE=$(echo "$R" | tail -1)
|
|
||||||
check_http "$CODE" "201" "Go: create → 201"
|
|
||||||
|
|
||||||
# Retry до 600s (60 итераций × 10s): первый прогон — ждём Fission rolling restart + build + pool pod.
|
|
||||||
STATUS=""; RAW=""
|
|
||||||
for _try in $(seq 1 60); do
|
|
||||||
sleep 10
|
|
||||||
INV=$(curl -s -X POST ${BASE}/functions/go-fn/invoke \
|
|
||||||
-H "X-Test-Sub: ${GO_NS}")
|
|
||||||
STATUS=$(echo "$INV" | python3 -c "import sys,json; print(json.load(sys.stdin).get('status','?'))" 2>/dev/null)
|
|
||||||
RAW=$(echo "$INV" | python3 -c "import sys,json; print(json.load(sys.stdin).get('response_raw','?'))" 2>/dev/null)
|
|
||||||
[ "$STATUS" = "200" ] && break
|
|
||||||
done
|
|
||||||
[ "$STATUS" = "200" ] && pass "Go: invoke → 200" || fail "Go: invoke → got $STATUS (body: $RAW)"
|
|
||||||
echo "$RAW" | grep -q "go-ok" && pass "Go: тело содержит go-ok" || fail "Go: тело не содержит go-ok (got: $RAW)"
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
echo ""
|
echo ""
|
||||||
echo "========================================"
|
echo "========================================"
|
||||||
|
|||||||
@@ -1,10 +1,3 @@
|
|||||||
api_token = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJhdXRoLWFwaSIsInN1YiI6IjAxOWNjMjY4LTZjNmEtNzgxZS04NjEzLTRiZWQ0ZWM3Y2QyMCIsImV4cCI6MTc4OTAxNTg2NywiaWF0IjoxNzczNDYzODY3LCJqdGkiOiJmYjRkMGFiNy1lYT>
|
ntazetdinov@nubes.ru
|
||||||
# s3_uid = "332cdb0d-34bf-43bf-864d-4adcc3b556fc"
|
|
||||||
s3_uid = "332cdb0d-34bf-43bf-864d-4adcc3b556fb"
|
|
||||||
# realm = "k8s-5-sandbox-nubes-ru"
|
|
||||||
realm = "k8s-3-sandbox-nubes-ru"
|
|
||||||
#realm = "naeel-test-3"
|
|
||||||
|
|
||||||
|
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJhdXRoLWFwaSIsInN1YiI6IjAxOWQ3NmM1LTQyNGMtN2MyYy04OWI3LWUzZjA5OTQ5YTk4ZiIsImV4cCI6MTc5MzA3ODQzOSwiaWF0IjoxNzc3NTI2NDM5LCJqdGkiOiI4MTU1ODNkNy0xZTRkLTRiMmMtOGM5ZS0wZDZmMGU2NmJjNTciLCJhdXRoX3RpbWUiOjAsInR5cCI6IiIsImF6cCI6IiIsInNlc3Npb25fc3RhdGUiOiIiLCJhY3IiOiIiLCJhbGxvd2VkLW9yaWdpbnMiOm51bGwsInJlYWxtX2FjY2VzcyI6eyJyb2xlcyI6bnVsbH0sInJlc291cmNlX2FjY2VzcyI6eyJhY2NvdW50Ijp7InJvbGVzIjpudWxsfX0sInNjb3BlIjoiIiwic2lkIjoiIiwiZW1haWxfdmVyaWZpZWQiOmZhbHNlLCJuYW1lIjoiIiwiQ2xpZW50SUQiOiJXWjAxMTEyIiwiY29tcGFueV9pZCI6IjhlYzcwYWMwLTU0NmQtNDJhNy04Y2ZmLTMzOWM4ZmI1MWEyMyIsImNvbXBhbnlfbmFtZSI6ItCe0J7QniDCq9Cd0KPQkdCV0KHCuyIsImlkcF91c3JfdWlkIjoiMDE5ZDc2YzUtNDI0Yy03YzJjLTg5YjctZTNmMDk5NDlhOThmIiwibG9naW4iOiJudGF6ZXRkaW5vdkBudWJlcy5ydSIsImZpcnN0bmFtZSI6ItCd0LDQuNC70YwiLCJtaWRkbGVuYW1lIjoi0KTQsNGA0LjRgtC-0LLQuNGHIiwibGFzdG5hbWUiOiLQotCw0LfQtdGC0LTQuNC90L7QsiIsImdyb3VwcyI6bnVsbCwicHJlZmVycmVkX3VzZXJuYW1lIjoiIiwiZ2l2ZW5fbmFtZSI6IiIsImZhbWlseV9uYW1lIjoiIiwiZW1haWwiOiJudGF6ZXRkaW5vdkBudWJlcy5ydSJ9.SaZsKAha45-fvKuJkLHUe_09AFsbH5QpzBVdPnjiEDQGIhl1A3ThgnM-oEh_H6CBXXfnOs2QDczzgCD8K8_tYzZKU1Wgk5lj04YW_fbTI89kHTO5wLtrAht9tFEjzBQZ-kmnG8mUK5tgqyNEjsngdQcfqVWRvneF366TiiRXk_76poUPpXWQmqdgCCKb3wq1rRgWcKDZcUC3JvDpOaqYL40zITZlM855drlJhMut4Gkg-EEk7ZGykl6YCsTRWVjFKGQ-7C-BDU2dkmIWhxzBAkvzzM2kBwvtlczCEDiUin7bXE-F_io89oJUYDzY4yPeHzwREwxCp6x9eD1NAIKKuA
|
||||||
|
|
||||||
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJhdXRoLWFwaSIsInN1YiI6IjAxOWNjMjY4LTZjNmEtNzgxZS04NjEzLTRiZWQ0ZWM3Y2QyMCIsImV4cCI6MTc4OTAxNTg2Nywia
|
|
||||||
Reference in New Issue
Block a user