refactor: extract metrics-collector from fission-console, bump console to v1.3.12
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
FROM golang:1.22-alpine AS builder
|
||||
WORKDIR /build
|
||||
COPY go.mod ./
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o metrics-collector ./cmd/server/
|
||||
|
||||
FROM alpine:3.20
|
||||
RUN apk add --no-cache ca-certificates
|
||||
COPY --from=builder /build/metrics-collector /metrics-collector
|
||||
EXPOSE 8091
|
||||
ENTRYPOINT ["/metrics-collector"]
|
||||
@@ -0,0 +1,40 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"metrics-collector/internal/api"
|
||||
"metrics-collector/ui"
|
||||
)
|
||||
|
||||
func main() {
|
||||
port := os.Getenv("PORT")
|
||||
if port == "" {
|
||||
port = "8091"
|
||||
}
|
||||
|
||||
srv := api.NewServer()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
_, _ = w.Write([]byte("ok\n"))
|
||||
})
|
||||
mux.HandleFunc("/metrics", srv.HandleMetrics)
|
||||
mux.Handle("/", ui.Handler())
|
||||
|
||||
httpSrv := &http.Server{
|
||||
Addr: ":" + port,
|
||||
Handler: mux,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 15 * time.Second,
|
||||
}
|
||||
|
||||
log.Printf("metrics-collector listening on :%s", port)
|
||||
if err := httpSrv.ListenAndServe(); err != nil {
|
||||
log.Fatalf("listen: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: metrics-collector
|
||||
namespace: fission
|
||||
labels:
|
||||
app: metrics-collector
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: metrics-collector
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: metrics-collector
|
||||
spec:
|
||||
containers:
|
||||
- name: metrics-collector
|
||||
image: naeel/metrics-collector:v0.1.0
|
||||
ports:
|
||||
- containerPort: 8091
|
||||
env:
|
||||
- name: PORT
|
||||
value: "8091"
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 8091
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 20
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 8091
|
||||
initialDelaySeconds: 3
|
||||
periodSeconds: 10
|
||||
resources:
|
||||
requests:
|
||||
cpu: 10m
|
||||
memory: 16Mi
|
||||
limits:
|
||||
cpu: 100m
|
||||
memory: 64Mi
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: metrics-collector
|
||||
namespace: fission
|
||||
spec:
|
||||
selector:
|
||||
app: metrics-collector
|
||||
ports:
|
||||
- port: 8091
|
||||
targetPort: 8091
|
||||
@@ -0,0 +1,3 @@
|
||||
module metrics-collector
|
||||
|
||||
go 1.22
|
||||
@@ -0,0 +1,215 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Metric — один снимок состояния, принятый через POST /metrics.
|
||||
type Metric 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"`
|
||||
}
|
||||
|
||||
const maxHistory = 240
|
||||
|
||||
// Server хранит историю снимков в памяти.
|
||||
type Server struct {
|
||||
mu sync.RWMutex
|
||||
history []Metric
|
||||
}
|
||||
|
||||
func NewServer() *Server {
|
||||
return &Server{}
|
||||
}
|
||||
|
||||
// HandleMetrics — единственный публичный эндпоинт.
|
||||
// GET /metrics → отдаёт историю.
|
||||
// POST /metrics → сохраняет снимок.
|
||||
func (s *Server) HandleMetrics(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, X-Metrics-Token")
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodOptions:
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
|
||||
case http.MethodGet:
|
||||
s.mu.RLock()
|
||||
history := append([]Metric(nil), s.history...)
|
||||
s.mu.RUnlock()
|
||||
|
||||
latest := Metric{}
|
||||
if len(history) > 0 {
|
||||
latest = history[len(history)-1]
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"count": len(history),
|
||||
"latest": latest,
|
||||
"history": history,
|
||||
})
|
||||
|
||||
case http.MethodPost:
|
||||
if !s.authorized(r) {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]any{"error": "invalid token"})
|
||||
return
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid json"})
|
||||
return
|
||||
}
|
||||
m := normalize(payload)
|
||||
s.mu.Lock()
|
||||
s.history = append(s.history, m)
|
||||
if len(s.history) > maxHistory {
|
||||
s.history = append([]Metric(nil), s.history[len(s.history)-maxHistory:]...)
|
||||
}
|
||||
count := len(s.history)
|
||||
s.mu.Unlock()
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "stored": count})
|
||||
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
// authorized проверяет METRICS_TOKEN если он задан.
|
||||
func (s *Server) authorized(r *http.Request) bool {
|
||||
expected := strings.TrimSpace(os.Getenv("METRICS_TOKEN"))
|
||||
if expected == "" {
|
||||
return true
|
||||
}
|
||||
return strings.TrimSpace(r.Header.Get("X-Metrics-Token")) == expected
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, code int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(code)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
// normalize приводит произвольный payload к структуре Metric.
|
||||
func normalize(p map[string]any) Metric {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
m := Metric{
|
||||
Timestamp: now,
|
||||
Source: textVal(p, "source", "function", "name", "host", "hostname", "instance"),
|
||||
Hostname: textVal(p, "hostname", "host", "node", "instance"),
|
||||
Status: textVal(p, "status"),
|
||||
Notes: textVal(p, "notes", "note", "message"),
|
||||
}
|
||||
if m.Source == "" {
|
||||
m.Source = "function"
|
||||
}
|
||||
|
||||
m.MemoryTotalMB = numVal(p, "memory_total_mb", "total_memory_mb", "mem_total_mb")
|
||||
m.MemoryFreeMB = numVal(p, "memory_free_mb", "free_memory_mb", "mem_free_mb", "free_mb")
|
||||
m.MemoryAvailMB = numVal(p, "memory_available_mb", "available_memory_mb", "mem_available_mb")
|
||||
m.MemoryUsedMB = numVal(p, "memory_used_mb", "used_memory_mb", "mem_used_mb", "used_mb")
|
||||
m.MemoryPercent = numVal(p, "memory_percent", "mem_percent", "memory_usage_percent")
|
||||
|
||||
if m.MemoryUsedMB == 0 && m.MemoryTotalMB > 0 && m.MemoryFreeMB > 0 {
|
||||
m.MemoryUsedMB = m.MemoryTotalMB - m.MemoryFreeMB
|
||||
}
|
||||
if m.MemoryAvailMB == 0 && m.MemoryFreeMB > 0 {
|
||||
m.MemoryAvailMB = m.MemoryFreeMB
|
||||
}
|
||||
if m.MemoryPercent == 0 && m.MemoryTotalMB > 0 && m.MemoryUsedMB > 0 {
|
||||
m.MemoryPercent = (m.MemoryUsedMB / m.MemoryTotalMB) * 100
|
||||
}
|
||||
|
||||
m.CpuLoad1m = numVal(p, "cpu_load_1m", "loadavg_1m", "load_1m", "load1")
|
||||
m.CpuLoad5m = numVal(p, "cpu_load_5m", "loadavg_5m", "load_5m", "load5")
|
||||
m.CpuLoad15m = numVal(p, "cpu_load_15m", "loadavg_15m", "load_15m", "load15")
|
||||
|
||||
m.DiskTotalGB = numVal(p, "disk_total_gb", "total_disk_gb")
|
||||
m.DiskFreeGB = numVal(p, "disk_free_gb", "free_disk_gb")
|
||||
m.DiskUsedGB = numVal(p, "disk_used_gb", "used_disk_gb")
|
||||
if m.DiskUsedGB == 0 && m.DiskTotalGB > 0 && m.DiskFreeGB > 0 {
|
||||
m.DiskUsedGB = m.DiskTotalGB - m.DiskFreeGB
|
||||
}
|
||||
|
||||
m.UptimeSec = numVal(p, "uptime_sec", "uptime", "uptime_seconds")
|
||||
|
||||
if ts := textVal(p, "timestamp", "ts", "collected_at"); ts != "" {
|
||||
m.Timestamp = ts
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func textVal(p map[string]any, keys ...string) string {
|
||||
for _, k := range keys {
|
||||
if v, ok := p[k]; ok {
|
||||
if s := strings.TrimSpace(anyToString(v)); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func numVal(p map[string]any, keys ...string) float64 {
|
||||
for _, k := range keys {
|
||||
if v, ok := p[k]; ok {
|
||||
if f, ok := anyToFloat64(v); ok {
|
||||
return f
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func anyToString(v any) string {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return t
|
||||
case float64:
|
||||
return strconv.FormatFloat(t, 'f', -1, 64)
|
||||
case json.Number:
|
||||
return t.String()
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func anyToFloat64(v any) (float64, bool) {
|
||||
switch t := v.(type) {
|
||||
case float64:
|
||||
return t, true
|
||||
case float32:
|
||||
return float64(t), true
|
||||
case int:
|
||||
return float64(t), true
|
||||
case int64:
|
||||
return float64(t), true
|
||||
case json.Number:
|
||||
f, err := t.Float64()
|
||||
return f, err == nil
|
||||
case string:
|
||||
f, err := strconv.ParseFloat(strings.TrimSpace(t), 64)
|
||||
return f, err == nil
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
//go:embed index.html
|
||||
var page string
|
||||
|
||||
func Handler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(page))
|
||||
})
|
||||
}
|
||||
@@ -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 = '/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>
|
||||
Reference in New Issue
Block a user