mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-31 01:10:41 +01:00
admin: native at-a-glance trend sparklines on the dashboard (#9957)
* admin: native at-a-glance trend sparklines on the dashboard Add a "Cluster Trends" row to the admin Dashboard with inline-SVG sparklines for volumes, EC shards, disk used, files, active maintenance tasks, and workers. The data comes entirely from what the admin already holds — the cached cluster topology and the in-process maintenance queue — sampled into a small bounded ring buffer on the existing maintenance-metrics ticker (~15 min of history). No Prometheus/Grafana dependency, no JS chart library, no extra goroutine: the sparklines are self-contained SVG rendered server-side via templ. This gives basic trend visibility out of the box for clusters that don't run Prometheus, and a quick glance next to the cluster controls; Grafana remains the place for deep/historical dashboards. * admin: cap trendBytes unit index to avoid out-of-bounds panic A value >= 1 ZiB would push exp past the end of the units string and panic on units[exp]; cap exp at the last unit (EiB).
This commit is contained in:
1 parent
c1636ac41c
commit
b56d155b31
5 files changed
+690
-356
No files matched your search
@@ -37,6 +37,10 @@ type AdminData struct {
|
||||
// EC shard totals for dashboard
|
||||
TotalEcVolumes int `json:"total_ec_volumes"` // Total number of EC volumes across all servers
|
||||
TotalEcShards int `json:"total_ec_shards"` // Total number of EC shards across all servers
|
||||
|
||||
// Trends holds at-a-glance sparklines built from the admin's own recent
|
||||
// cluster snapshots (no Prometheus required).
|
||||
Trends DashboardTrends `json:"trends"`
|
||||
}
|
||||
|
||||
// Object Store Users management structures
|
||||
@@ -198,6 +202,7 @@ func (s *AdminServer) GetAdminData(username string) (AdminData, error) {
|
||||
LastUpdated: topology.UpdatedAt,
|
||||
TotalEcVolumes: totalEcVolumes,
|
||||
TotalEcShards: totalEcShards,
|
||||
Trends: s.GetDashboardTrends(),
|
||||
}
|
||||
|
||||
return adminData, nil
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/admin/maintenance"
|
||||
@@ -89,6 +90,12 @@ type AdminServer struct {
|
||||
lastCacheUpdate time.Time
|
||||
cachedTopology *ClusterTopology
|
||||
|
||||
// dashSamples is a bounded in-memory ring of recent cluster snapshots that
|
||||
// powers the dashboard's at-a-glance sparklines (see dashboard_metrics.go),
|
||||
// filled on the maintenance-metrics ticker. No Prometheus dependency.
|
||||
dashSamples []dashSample
|
||||
dashSamplesMu sync.Mutex
|
||||
|
||||
// Filer discovery and caching
|
||||
cachedFilers []string
|
||||
lastFilerUpdate time.Time
|
||||
@@ -375,12 +382,16 @@ func (s *AdminServer) publishMaintenanceMetrics(ctx context.Context) {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Seed one sample so the dashboard has something to draw before the first tick.
|
||||
s.recordDashboardSample()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.collectMaintenanceMetrics()
|
||||
s.recordDashboardSample()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
package dash
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// dashMaxSamples bounds the in-memory trend ring buffer. At the 15s sample
|
||||
// cadence (piggy-backed on publishMaintenanceMetrics) this is ~15 minutes.
|
||||
const dashMaxSamples = 60
|
||||
|
||||
// dashSample is one point-in-time snapshot of a few headline cluster numbers,
|
||||
// derived from data the admin already holds (cluster topology + the in-process
|
||||
// maintenance queue) — no Prometheus scrape required.
|
||||
type dashSample struct {
|
||||
t time.Time
|
||||
volumes float64
|
||||
ecShards float64
|
||||
diskUsed float64
|
||||
files float64
|
||||
tasks float64 // pending/assigned/in-progress maintenance tasks
|
||||
workers float64
|
||||
}
|
||||
|
||||
// TrendCard is one at-a-glance metric rendered on the dashboard: a current
|
||||
// value plus an inline SVG sparkline of its recent history.
|
||||
type TrendCard struct {
|
||||
Title string
|
||||
Value string
|
||||
Icon string // FontAwesome icon class, e.g. "fa-database"
|
||||
Color string // Bootstrap contextual color, e.g. "primary"
|
||||
SparkSVG string // self-contained inline <svg>…</svg>
|
||||
}
|
||||
|
||||
// DashboardTrends is the set of trend cards shown on the dashboard.
|
||||
type DashboardTrends struct {
|
||||
Cards []TrendCard `json:"-"`
|
||||
Samples int `json:"samples"`
|
||||
}
|
||||
|
||||
// recordDashboardSample snapshots headline cluster numbers into the ring
|
||||
// buffer. Cheap: topology is already cached, and the maintenance stats are
|
||||
// in-memory. Called on the existing maintenance-metrics ticker.
|
||||
func (s *AdminServer) recordDashboardSample() {
|
||||
topology, err := s.GetClusterTopology()
|
||||
if err != nil || topology == nil {
|
||||
return
|
||||
}
|
||||
ecShards := 0
|
||||
for _, vs := range topology.VolumeServers {
|
||||
ecShards += vs.EcShards
|
||||
}
|
||||
sample := dashSample{
|
||||
t: time.Now(),
|
||||
volumes: float64(topology.TotalVolumes),
|
||||
ecShards: float64(ecShards),
|
||||
diskUsed: float64(topology.TotalSize),
|
||||
files: float64(topology.TotalFiles),
|
||||
}
|
||||
if s.maintenanceManager != nil {
|
||||
if stats := s.maintenanceManager.GetStats(); stats != nil {
|
||||
active := 0
|
||||
for status, n := range stats.TasksByStatus {
|
||||
switch string(status) {
|
||||
case "pending", "assigned", "in_progress":
|
||||
active += n
|
||||
}
|
||||
}
|
||||
sample.tasks = float64(active)
|
||||
sample.workers = float64(stats.ActiveWorkers)
|
||||
}
|
||||
}
|
||||
|
||||
s.dashSamplesMu.Lock()
|
||||
s.dashSamples = append(s.dashSamples, sample)
|
||||
if len(s.dashSamples) > dashMaxSamples {
|
||||
s.dashSamples = s.dashSamples[len(s.dashSamples)-dashMaxSamples:]
|
||||
}
|
||||
s.dashSamplesMu.Unlock()
|
||||
}
|
||||
|
||||
// GetDashboardTrends builds the trend cards from the current ring buffer.
|
||||
func (s *AdminServer) GetDashboardTrends() DashboardTrends {
|
||||
s.dashSamplesMu.Lock()
|
||||
samples := make([]dashSample, len(s.dashSamples))
|
||||
copy(samples, s.dashSamples)
|
||||
s.dashSamplesMu.Unlock()
|
||||
|
||||
series := func(pick func(dashSample) float64) []float64 {
|
||||
out := make([]float64, len(samples))
|
||||
for i, smp := range samples {
|
||||
out[i] = pick(smp)
|
||||
}
|
||||
return out
|
||||
}
|
||||
vol := series(func(s dashSample) float64 { return s.volumes })
|
||||
ec := series(func(s dashSample) float64 { return s.ecShards })
|
||||
disk := series(func(s dashSample) float64 { return s.diskUsed })
|
||||
files := series(func(s dashSample) float64 { return s.files })
|
||||
tasks := series(func(s dashSample) float64 { return s.tasks })
|
||||
workers := series(func(s dashSample) float64 { return s.workers })
|
||||
|
||||
cards := []TrendCard{
|
||||
{Title: "Volumes", Value: trendCount(last(vol)), Icon: "fa-database", Color: "primary", SparkSVG: sparklineSVG(vol, "#4e73df")},
|
||||
{Title: "EC Shards", Value: trendCount(last(ec)), Icon: "fa-th-large", Color: "info", SparkSVG: sparklineSVG(ec, "#36b9cc")},
|
||||
{Title: "Disk Used", Value: trendBytes(last(disk)), Icon: "fa-hdd", Color: "success", SparkSVG: sparklineSVG(disk, "#1cc88a")},
|
||||
{Title: "Files", Value: trendCount(last(files)), Icon: "fa-file", Color: "warning", SparkSVG: sparklineSVG(files, "#f6c23e")},
|
||||
{Title: "Active Tasks", Value: trendCount(last(tasks)), Icon: "fa-tasks", Color: "secondary", SparkSVG: sparklineSVG(tasks, "#858796")},
|
||||
{Title: "Workers", Value: trendCount(last(workers)), Icon: "fa-users-cog", Color: "dark", SparkSVG: sparklineSVG(workers, "#5a5c69")},
|
||||
}
|
||||
return DashboardTrends{Cards: cards, Samples: len(samples)}
|
||||
}
|
||||
|
||||
func last(v []float64) float64 {
|
||||
if len(v) == 0 {
|
||||
return 0
|
||||
}
|
||||
return v[len(v)-1]
|
||||
}
|
||||
|
||||
// sparklineSVG renders a fixed-viewBox, width-responsive inline SVG line of the
|
||||
// given points. Self-contained (no JS/chart lib); safe to inline in the page.
|
||||
func sparklineSVG(pts []float64, color string) string {
|
||||
const w, h = 240.0, 48.0
|
||||
if len(pts) < 2 {
|
||||
// Not enough history yet — draw a flat baseline so the card isn't empty.
|
||||
return fmt.Sprintf(`<svg viewBox="0 0 %g %g" preserveAspectRatio="none" style="width:100%%;height:48px"><line x1="0" y1="%g" x2="%g" y2="%g" stroke="%s" stroke-width="2" opacity="0.4"/></svg>`, w, h, h/2, w, h/2, color)
|
||||
}
|
||||
minV, maxV := pts[0], pts[0]
|
||||
for _, v := range pts {
|
||||
if v < minV {
|
||||
minV = v
|
||||
}
|
||||
if v > maxV {
|
||||
maxV = v
|
||||
}
|
||||
}
|
||||
span := maxV - minV
|
||||
if span == 0 {
|
||||
span = 1
|
||||
}
|
||||
dx := w / float64(len(pts)-1)
|
||||
var line strings.Builder
|
||||
for i, v := range pts {
|
||||
x := float64(i) * dx
|
||||
y := h - 3 - (v-minV)/span*(h-6) // 3px padding top/bottom; SVG y grows down
|
||||
if i == 0 {
|
||||
fmt.Fprintf(&line, "M%.1f %.1f", x, y)
|
||||
} else {
|
||||
fmt.Fprintf(&line, " L%.1f %.1f", x, y)
|
||||
}
|
||||
}
|
||||
// Area path closes back along the baseline for a subtle fill.
|
||||
area := fmt.Sprintf("%s L%.1f %.1f L0 %.1f Z", line.String(), w, h, h)
|
||||
return fmt.Sprintf(`<svg viewBox="0 0 %g %g" preserveAspectRatio="none" style="width:100%%;height:48px"><path d="%s" fill="%s" opacity="0.12"/><path d="%s" fill="none" stroke="%s" stroke-width="2"/></svg>`,
|
||||
w, h, area, color, line.String(), color)
|
||||
}
|
||||
|
||||
// trendCount formats a count with thousands separators.
|
||||
func trendCount(v float64) string {
|
||||
n := int64(v)
|
||||
s := fmt.Sprintf("%d", n)
|
||||
if n < 0 {
|
||||
return s
|
||||
}
|
||||
var out []byte
|
||||
for i, c := range []byte(s) {
|
||||
if i > 0 && (len(s)-i)%3 == 0 {
|
||||
out = append(out, ',')
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// trendBytes formats a byte count as a human-readable size.
|
||||
func trendBytes(v float64) string {
|
||||
const unit = 1024.0
|
||||
if v < unit {
|
||||
return fmt.Sprintf("%.0f B", v)
|
||||
}
|
||||
const units = "KMGTPE"
|
||||
div, exp := unit, 0
|
||||
// Cap exp at the last unit so an anomalously huge value can't index past
|
||||
// the units string and panic.
|
||||
for n := v / unit; n >= unit && exp < len(units)-1; n /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %ciB", v/div, units[exp])
|
||||
}
|
||||
@@ -21,6 +21,31 @@ templ Admin(data dash.AdminData) {
|
||||
</div>
|
||||
|
||||
<div id="dashboard-content">
|
||||
<!-- Cluster Trends: native at-a-glance sparklines from the admin's own
|
||||
recent snapshots (no Prometheus required). -->
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<h6 class="text-uppercase text-muted small mb-0">
|
||||
<i class="fas fa-chart-line me-1"></i>Cluster Trends
|
||||
</h6>
|
||||
<span class="text-muted small ms-2">({ fmt.Sprintf("%d", data.Trends.Samples) } recent samples)</span>
|
||||
</div>
|
||||
<div class="row mb-4">
|
||||
for _, c := range data.Trends.Cards {
|
||||
<div class="col-xl-2 col-md-4 col-6 mb-3">
|
||||
<div class="card shadow h-100 py-2">
|
||||
<div class="card-body py-2 px-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<span class={ fmt.Sprintf("text-xs font-weight-bold text-uppercase text-%s", c.Color) }>{ c.Title }</span>
|
||||
<i class={ fmt.Sprintf("fas %s text-gray-300", c.Icon) }></i>
|
||||
</div>
|
||||
<div class="h5 mb-1 font-weight-bold text-gray-800">{ c.Value }</div>
|
||||
@templ.Raw(c.SparkSVG)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Summary Cards -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-xl-3 col-md-6 mb-4">
|
||||
|
||||
+457
-356
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user