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:
Chris Lu authored and GitHub committed 2026-06-14 13:55:26 -07:00
1 parent c1636ac41c
commit b56d155b31
5 files changed
+690 -356

No files matched your search

+5
View File
@@ -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
+11
View File
@@ -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()
}
}
}
+192
View File
@@ -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])
}
+25
View File
@@ -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">
File diff suppressed because it is too large. Load diff