Unify live metrics chart rendering
This commit is contained in:
@@ -346,8 +346,10 @@ func (h *handler) handleAPINetworkStatus(w http.ResponseWriter, r *http.Request)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, map[string]any{
|
writeJSON(w, map[string]any{
|
||||||
"interfaces": ifaces,
|
"interfaces": ifaces,
|
||||||
"default_route": h.opts.App.DefaultRoute(),
|
"default_route": h.opts.App.DefaultRoute(),
|
||||||
|
"pending_change": h.hasPendingNetworkChange(),
|
||||||
|
"rollback_in": h.pendingNetworkRollbackIn(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -847,7 +849,10 @@ func (h *handler) applyPendingNetworkChange(apply func() (app.ActionResult, erro
|
|||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
|
|
||||||
pnc := &pendingNetChange{snapshot: snapshot}
|
pnc := &pendingNetChange{
|
||||||
|
snapshot: snapshot,
|
||||||
|
deadline: time.Now().Add(netRollbackTimeout),
|
||||||
|
}
|
||||||
pnc.timer = time.AfterFunc(netRollbackTimeout, func() {
|
pnc.timer = time.AfterFunc(netRollbackTimeout, func() {
|
||||||
_ = h.opts.App.RestoreNetworkSnapshot(snapshot)
|
_ = h.opts.App.RestoreNetworkSnapshot(snapshot)
|
||||||
h.pendingNetMu.Lock()
|
h.pendingNetMu.Lock()
|
||||||
@@ -864,6 +869,25 @@ func (h *handler) applyPendingNetworkChange(apply func() (app.ActionResult, erro
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *handler) hasPendingNetworkChange() bool {
|
||||||
|
h.pendingNetMu.Lock()
|
||||||
|
defer h.pendingNetMu.Unlock()
|
||||||
|
return h.pendingNet != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) pendingNetworkRollbackIn() int {
|
||||||
|
h.pendingNetMu.Lock()
|
||||||
|
defer h.pendingNetMu.Unlock()
|
||||||
|
if h.pendingNet == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
remaining := int(time.Until(h.pendingNet.deadline).Seconds())
|
||||||
|
if remaining < 1 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return remaining
|
||||||
|
}
|
||||||
|
|
||||||
func (h *handler) handleAPINetworkConfirm(w http.ResponseWriter, _ *http.Request) {
|
func (h *handler) handleAPINetworkConfirm(w http.ResponseWriter, _ *http.Request) {
|
||||||
h.pendingNetMu.Lock()
|
h.pendingNetMu.Lock()
|
||||||
pnc := h.pendingNet
|
pnc := h.pendingNet
|
||||||
|
|||||||
@@ -522,13 +522,30 @@ func renderMetrics() string {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
const chartIds = [
|
||||||
|
'chart-server-load','chart-server-temp-cpu','chart-server-temp-gpu','chart-server-temp-ambient','chart-server-power','chart-server-fans',
|
||||||
|
'chart-gpu-all-load','chart-gpu-all-memload','chart-gpu-all-power','chart-gpu-all-temp'
|
||||||
|
];
|
||||||
|
|
||||||
|
function refreshChartImage(el) {
|
||||||
|
if (!el || el.dataset.loading === '1') return;
|
||||||
|
const baseSrc = el.dataset.baseSrc || el.src.split('?')[0];
|
||||||
|
const nextSrc = baseSrc + '?t=' + Date.now();
|
||||||
|
const probe = new Image();
|
||||||
|
el.dataset.baseSrc = baseSrc;
|
||||||
|
el.dataset.loading = '1';
|
||||||
|
probe.onload = function() {
|
||||||
|
el.src = nextSrc;
|
||||||
|
el.dataset.loading = '0';
|
||||||
|
};
|
||||||
|
probe.onerror = function() {
|
||||||
|
el.dataset.loading = '0';
|
||||||
|
};
|
||||||
|
probe.src = nextSrc;
|
||||||
|
}
|
||||||
|
|
||||||
function refreshCharts() {
|
function refreshCharts() {
|
||||||
const t = '?t=' + Date.now();
|
chartIds.forEach(id => refreshChartImage(document.getElementById(id)));
|
||||||
['chart-server-load','chart-server-temp-cpu','chart-server-temp-gpu','chart-server-temp-ambient','chart-server-power','chart-server-fans',
|
|
||||||
'chart-gpu-all-load','chart-gpu-all-memload','chart-gpu-all-power','chart-gpu-all-temp'].forEach(id => {
|
|
||||||
const el = document.getElementById(id);
|
|
||||||
if (el) el.src = el.src.split('?')[0] + t;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
setInterval(refreshCharts, 3000);
|
setInterval(refreshCharts, 3000);
|
||||||
|
|
||||||
@@ -892,6 +909,8 @@ func renderNetworkInline() string {
|
|||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
var _netCountdownTimer = null;
|
var _netCountdownTimer = null;
|
||||||
|
var _netRefreshTimer = null;
|
||||||
|
const NET_ROLLBACK_SECS = 60;
|
||||||
function loadNetwork() {
|
function loadNetwork() {
|
||||||
fetch('/api/network').then(r=>r.json()).then(d => {
|
fetch('/api/network').then(r=>r.json()).then(d => {
|
||||||
const rows = (d.interfaces||[]).map(i =>
|
const rows = (d.interfaces||[]).map(i =>
|
||||||
@@ -902,21 +921,33 @@ function loadNetwork() {
|
|||||||
document.getElementById('iface-table').innerHTML =
|
document.getElementById('iface-table').innerHTML =
|
||||||
'<table><tr><th>Interface</th><th>State (click to toggle)</th><th>Addresses</th></tr>'+rows+'</table>' +
|
'<table><tr><th>Interface</th><th>State (click to toggle)</th><th>Addresses</th></tr>'+rows+'</table>' +
|
||||||
(d.default_route ? '<p style="font-size:12px;color:var(--muted);margin-top:8px">Default route: '+d.default_route+'</p>' : '');
|
(d.default_route ? '<p style="font-size:12px;color:var(--muted);margin-top:8px">Default route: '+d.default_route+'</p>' : '');
|
||||||
});
|
if (d.pending_change) showNetPending(d.rollback_in || NET_ROLLBACK_SECS);
|
||||||
|
else hideNetPending();
|
||||||
|
}).catch(function() {});
|
||||||
}
|
}
|
||||||
function selectIface(iface) {
|
function selectIface(iface) {
|
||||||
document.getElementById('dhcp-iface').value = iface;
|
document.getElementById('dhcp-iface').value = iface;
|
||||||
document.getElementById('st-iface').value = iface;
|
document.getElementById('st-iface').value = iface;
|
||||||
}
|
}
|
||||||
function toggleIface(iface, currentState) {
|
function toggleIface(iface, currentState) {
|
||||||
|
showNetPending(NET_ROLLBACK_SECS);
|
||||||
fetch('/api/network/toggle',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({iface:iface})})
|
fetch('/api/network/toggle',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({iface:iface})})
|
||||||
.then(r=>r.json()).then(d => {
|
.then(r=>r.json()).then(d => {
|
||||||
if (d.error) { alert('Error: '+d.error); return; }
|
if (d.error) { hideNetPending(); alert('Error: '+d.error); return; }
|
||||||
loadNetwork();
|
loadNetwork();
|
||||||
showNetPending(d.rollback_in || 60);
|
showNetPending(d.rollback_in || NET_ROLLBACK_SECS);
|
||||||
|
}).catch(function() {
|
||||||
|
setTimeout(loadNetwork, 1500);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
function hideNetPending() {
|
||||||
|
const el = document.getElementById('net-pending');
|
||||||
|
if (_netCountdownTimer) clearInterval(_netCountdownTimer);
|
||||||
|
_netCountdownTimer = null;
|
||||||
|
el.style.display = 'none';
|
||||||
|
}
|
||||||
function showNetPending(secs) {
|
function showNetPending(secs) {
|
||||||
|
if (!secs || secs < 1) { hideNetPending(); return; }
|
||||||
const el = document.getElementById('net-pending');
|
const el = document.getElementById('net-pending');
|
||||||
el.style.display = 'block';
|
el.style.display = 'block';
|
||||||
if (_netCountdownTimer) clearInterval(_netCountdownTimer);
|
if (_netCountdownTimer) clearInterval(_netCountdownTimer);
|
||||||
@@ -925,30 +956,33 @@ function showNetPending(secs) {
|
|||||||
_netCountdownTimer = setInterval(function() {
|
_netCountdownTimer = setInterval(function() {
|
||||||
remaining--;
|
remaining--;
|
||||||
document.getElementById('net-countdown').textContent = remaining;
|
document.getElementById('net-countdown').textContent = remaining;
|
||||||
if (remaining <= 0) { clearInterval(_netCountdownTimer); _netCountdownTimer=null; el.style.display='none'; loadNetwork(); }
|
if (remaining <= 0) { hideNetPending(); loadNetwork(); }
|
||||||
}, 1000);
|
}, 1000);
|
||||||
}
|
}
|
||||||
function confirmNetChange() {
|
function confirmNetChange() {
|
||||||
if (_netCountdownTimer) { clearInterval(_netCountdownTimer); _netCountdownTimer=null; }
|
hideNetPending();
|
||||||
document.getElementById('net-pending').style.display='none';
|
fetch('/api/network/confirm',{method:'POST'}).then(()=>loadNetwork()).catch(()=>{});
|
||||||
fetch('/api/network/confirm',{method:'POST'});
|
|
||||||
}
|
}
|
||||||
function rollbackNetChange() {
|
function rollbackNetChange() {
|
||||||
if (_netCountdownTimer) { clearInterval(_netCountdownTimer); _netCountdownTimer=null; }
|
hideNetPending();
|
||||||
document.getElementById('net-pending').style.display='none';
|
fetch('/api/network/rollback',{method:'POST'}).then(()=>loadNetwork()).catch(()=>{});
|
||||||
fetch('/api/network/rollback',{method:'POST'}).then(()=>loadNetwork());
|
|
||||||
}
|
}
|
||||||
function runDHCP() {
|
function runDHCP() {
|
||||||
const iface = document.getElementById('dhcp-iface').value.trim();
|
const iface = document.getElementById('dhcp-iface').value.trim();
|
||||||
|
showNetPending(NET_ROLLBACK_SECS);
|
||||||
fetch('/api/network/dhcp',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({interface:iface||'all'})})
|
fetch('/api/network/dhcp',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({interface:iface||'all'})})
|
||||||
.then(r=>r.json()).then(d => {
|
.then(r=>r.json()).then(d => {
|
||||||
document.getElementById('dhcp-out').textContent = d.output || d.error || 'Done.';
|
document.getElementById('dhcp-out').textContent = d.output || d.error || 'Done.';
|
||||||
if (!d.error) showNetPending(d.rollback_in || 60);
|
if (d.error) { hideNetPending(); return; }
|
||||||
|
showNetPending(d.rollback_in || NET_ROLLBACK_SECS);
|
||||||
loadNetwork();
|
loadNetwork();
|
||||||
|
}).catch(function() {
|
||||||
|
setTimeout(loadNetwork, 1500);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function setStatic() {
|
function setStatic() {
|
||||||
const dns = document.getElementById('st-dns').value.split(',').map(s=>s.trim()).filter(Boolean);
|
const dns = document.getElementById('st-dns').value.split(',').map(s=>s.trim()).filter(Boolean);
|
||||||
|
showNetPending(NET_ROLLBACK_SECS);
|
||||||
fetch('/api/network/static',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({
|
fetch('/api/network/static',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({
|
||||||
interface: document.getElementById('st-iface').value,
|
interface: document.getElementById('st-iface').value,
|
||||||
address: document.getElementById('st-addr').value,
|
address: document.getElementById('st-addr').value,
|
||||||
@@ -957,11 +991,16 @@ function setStatic() {
|
|||||||
dns: dns,
|
dns: dns,
|
||||||
})}).then(r=>r.json()).then(d => {
|
})}).then(r=>r.json()).then(d => {
|
||||||
document.getElementById('static-out').textContent = d.output || d.error || 'Done.';
|
document.getElementById('static-out').textContent = d.output || d.error || 'Done.';
|
||||||
if (!d.error) showNetPending(d.rollback_in || 60);
|
if (d.error) { hideNetPending(); return; }
|
||||||
|
showNetPending(d.rollback_in || NET_ROLLBACK_SECS);
|
||||||
loadNetwork();
|
loadNetwork();
|
||||||
|
}).catch(function() {
|
||||||
|
setTimeout(loadNetwork, 1500);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
loadNetwork();
|
loadNetwork();
|
||||||
|
if (_netRefreshTimer) clearInterval(_netRefreshTimer);
|
||||||
|
_netRefreshTimer = setInterval(loadNetwork, 5000);
|
||||||
</script>`
|
</script>`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ type namedMetricsRing struct {
|
|||||||
// pendingNetChange tracks a network state change awaiting confirmation.
|
// pendingNetChange tracks a network state change awaiting confirmation.
|
||||||
type pendingNetChange struct {
|
type pendingNetChange struct {
|
||||||
snapshot platform.NetworkSnapshot
|
snapshot platform.NetworkSnapshot
|
||||||
|
deadline time.Time
|
||||||
timer *time.Timer
|
timer *time.Timer
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
@@ -527,15 +528,7 @@ func (h *handler) handleMetricsChartSVG(w http.ResponseWriter, r *http.Request)
|
|||||||
case path == "server-fans":
|
case path == "server-fans":
|
||||||
title = "Fan RPM"
|
title = "Fan RPM"
|
||||||
h.ringsMu.Lock()
|
h.ringsMu.Lock()
|
||||||
for i, fr := range h.ringFans {
|
datasets, names, labels = snapshotFanRings(h.ringFans, h.fanNames)
|
||||||
fv, _ := fr.snapshot()
|
|
||||||
datasets = append(datasets, fv)
|
|
||||||
name := "Fan"
|
|
||||||
if i < len(h.fanNames) {
|
|
||||||
name = h.fanNames[i]
|
|
||||||
}
|
|
||||||
names = append(names, name+" RPM")
|
|
||||||
}
|
|
||||||
h.ringsMu.Unlock()
|
h.ringsMu.Unlock()
|
||||||
yMin = floatPtr(0)
|
yMin = floatPtr(0)
|
||||||
yMax = autoMax120(datasets...)
|
yMax = autoMax120(datasets...)
|
||||||
@@ -1044,15 +1037,17 @@ func renderChartSVG(title string, datasets [][]float64, names []string, labels [
|
|||||||
opt.Title = gocharts.TitleOption{Text: title}
|
opt.Title = gocharts.TitleOption{Text: title}
|
||||||
opt.XAxis.Labels = sparse
|
opt.XAxis.Labels = sparse
|
||||||
opt.Legend = gocharts.LegendOption{SeriesNames: names}
|
opt.Legend = gocharts.LegendOption{SeriesNames: names}
|
||||||
|
if chartLegendVisible(len(names)) {
|
||||||
|
opt.Legend.Offset = gocharts.OffsetStr{Top: gocharts.PositionBottom}
|
||||||
|
opt.Legend.OverlayChart = gocharts.Ptr(false)
|
||||||
|
} else {
|
||||||
|
opt.Legend.Show = gocharts.Ptr(false)
|
||||||
|
}
|
||||||
opt.Symbol = gocharts.SymbolNone
|
opt.Symbol = gocharts.SymbolNone
|
||||||
// Right padding: reserve space for the MarkLine label (library recommendation).
|
// Right padding: reserve space for the MarkLine label (library recommendation).
|
||||||
opt.Padding = gocharts.NewBox(20, 20, 80, 20)
|
opt.Padding = gocharts.NewBox(20, 20, 80, 20)
|
||||||
if yMin != nil || yMax != nil {
|
if yMin != nil || yMax != nil {
|
||||||
opt.YAxis = []gocharts.YAxisOption{{
|
opt.YAxis = []gocharts.YAxisOption{chartYAxisOption(yMin, yMax)}
|
||||||
Min: yMin,
|
|
||||||
Max: yMax,
|
|
||||||
ValueFormatter: chartLegendNumber,
|
|
||||||
}}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add a single peak mark line on the series that holds the global maximum.
|
// Add a single peak mark line on the series that holds the global maximum.
|
||||||
@@ -1064,7 +1059,7 @@ func renderChartSVG(title string, datasets [][]float64, names []string, labels [
|
|||||||
p := gocharts.NewPainter(gocharts.PainterOptions{
|
p := gocharts.NewPainter(gocharts.PainterOptions{
|
||||||
OutputFormat: gocharts.ChartOutputSVG,
|
OutputFormat: gocharts.ChartOutputSVG,
|
||||||
Width: 1400,
|
Width: 1400,
|
||||||
Height: 240,
|
Height: chartCanvasHeight(len(names)),
|
||||||
}, gocharts.PainterThemeOption(gocharts.GetTheme("grafana")))
|
}, gocharts.PainterThemeOption(gocharts.GetTheme("grafana")))
|
||||||
if err := p.LineChart(opt); err != nil {
|
if err := p.LineChart(opt); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -1072,6 +1067,26 @@ func renderChartSVG(title string, datasets [][]float64, names []string, labels [
|
|||||||
return p.Bytes()
|
return p.Bytes()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func chartLegendVisible(seriesCount int) bool {
|
||||||
|
return seriesCount <= 8
|
||||||
|
}
|
||||||
|
|
||||||
|
func chartCanvasHeight(seriesCount int) int {
|
||||||
|
if chartLegendVisible(seriesCount) {
|
||||||
|
return 360
|
||||||
|
}
|
||||||
|
return 288
|
||||||
|
}
|
||||||
|
|
||||||
|
func chartYAxisOption(yMin, yMax *float64) gocharts.YAxisOption {
|
||||||
|
return gocharts.YAxisOption{
|
||||||
|
Min: yMin,
|
||||||
|
Max: yMax,
|
||||||
|
LabelCount: 11,
|
||||||
|
ValueFormatter: chartYAxisNumber,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// globalPeakSeries returns the index of the series containing the global maximum
|
// globalPeakSeries returns the index of the series containing the global maximum
|
||||||
// value across all datasets, and that maximum value.
|
// value across all datasets, and that maximum value.
|
||||||
func globalPeakSeries(datasets [][]float64) (idx int, peak float64) {
|
func globalPeakSeries(datasets [][]float64) (idx int, peak float64) {
|
||||||
@@ -1159,6 +1174,28 @@ func snapshotNamedRings(rings []*namedMetricsRing) ([][]float64, []string, []str
|
|||||||
return datasets, names, labels
|
return datasets, names, labels
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func snapshotFanRings(rings []*metricsRing, fanNames []string) ([][]float64, []string, []string) {
|
||||||
|
var datasets [][]float64
|
||||||
|
var names []string
|
||||||
|
var labels []string
|
||||||
|
for i, ring := range rings {
|
||||||
|
if ring == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
vals, l := ring.snapshot()
|
||||||
|
datasets = append(datasets, vals)
|
||||||
|
name := "Fan"
|
||||||
|
if i < len(fanNames) {
|
||||||
|
name = fanNames[i]
|
||||||
|
}
|
||||||
|
names = append(names, name+" RPM")
|
||||||
|
if len(labels) == 0 {
|
||||||
|
labels = l
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return datasets, names, labels
|
||||||
|
}
|
||||||
|
|
||||||
func chartLegendNumber(v float64) string {
|
func chartLegendNumber(v float64) string {
|
||||||
neg := v < 0
|
neg := v < 0
|
||||||
if v < 0 {
|
if v < 0 {
|
||||||
@@ -1181,6 +1218,23 @@ func chartLegendNumber(v float64) string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func chartYAxisNumber(v float64) string {
|
||||||
|
neg := v < 0
|
||||||
|
if neg {
|
||||||
|
v = -v
|
||||||
|
}
|
||||||
|
var out string
|
||||||
|
if v >= 1000 {
|
||||||
|
out = fmt.Sprintf("%dк", int((v+500)/1000))
|
||||||
|
} else {
|
||||||
|
out = fmt.Sprintf("%.0f", v)
|
||||||
|
}
|
||||||
|
if neg {
|
||||||
|
return "-" + out
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func sparseLabels(labels []string, n int) []string {
|
func sparseLabels(labels []string, n int) []string {
|
||||||
out := make([]string, len(labels))
|
out := make([]string, len(labels))
|
||||||
step := len(labels) / n
|
step := len(labels) / n
|
||||||
|
|||||||
@@ -102,6 +102,104 @@ func TestNormalizePowerSeriesHoldsLastPositive(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRenderMetricsUsesBufferedChartRefresh(t *testing.T) {
|
||||||
|
body := renderMetrics()
|
||||||
|
if !strings.Contains(body, "const probe = new Image();") {
|
||||||
|
t.Fatalf("metrics page should preload chart images before swap: %s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "el.dataset.loading === '1'") {
|
||||||
|
t.Fatalf("metrics page should avoid overlapping chart reloads: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChartLegendVisible(t *testing.T) {
|
||||||
|
if !chartLegendVisible(8) {
|
||||||
|
t.Fatal("legend should stay visible for charts with up to 8 series")
|
||||||
|
}
|
||||||
|
if chartLegendVisible(9) {
|
||||||
|
t.Fatal("legend should be hidden for charts with more than 8 series")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChartYAxisNumber(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
in float64
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{in: 999, want: "999"},
|
||||||
|
{in: 1000, want: "1к"},
|
||||||
|
{in: 1370, want: "1к"},
|
||||||
|
{in: 1500, want: "2к"},
|
||||||
|
{in: 10200, want: "10к"},
|
||||||
|
{in: -1499, want: "-1к"},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
if got := chartYAxisNumber(tc.in); got != tc.want {
|
||||||
|
t.Fatalf("chartYAxisNumber(%v)=%q want %q", tc.in, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChartCanvasHeight(t *testing.T) {
|
||||||
|
if got := chartCanvasHeight(4); got != 360 {
|
||||||
|
t.Fatalf("chartCanvasHeight(4)=%d want 360", got)
|
||||||
|
}
|
||||||
|
if got := chartCanvasHeight(12); got != 288 {
|
||||||
|
t.Fatalf("chartCanvasHeight(12)=%d want 288", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChartYAxisOption(t *testing.T) {
|
||||||
|
min := floatPtr(0)
|
||||||
|
max := floatPtr(100)
|
||||||
|
opt := chartYAxisOption(min, max)
|
||||||
|
if opt.Min != min || opt.Max != max {
|
||||||
|
t.Fatalf("chartYAxisOption min/max mismatch: %#v", opt)
|
||||||
|
}
|
||||||
|
if opt.LabelCount != 11 {
|
||||||
|
t.Fatalf("chartYAxisOption labelCount=%d want 11", opt.LabelCount)
|
||||||
|
}
|
||||||
|
if got := opt.ValueFormatter(1000); got != "1к" {
|
||||||
|
t.Fatalf("chartYAxisOption formatter(1000)=%q want 1к", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSnapshotFanRingsUsesTimelineLabels(t *testing.T) {
|
||||||
|
r1 := newMetricsRing(4)
|
||||||
|
r2 := newMetricsRing(4)
|
||||||
|
r1.push(1000)
|
||||||
|
r1.push(1100)
|
||||||
|
r2.push(1200)
|
||||||
|
r2.push(1300)
|
||||||
|
|
||||||
|
datasets, names, labels := snapshotFanRings([]*metricsRing{r1, r2}, []string{"FAN_A", "FAN_B"})
|
||||||
|
if len(datasets) != 2 {
|
||||||
|
t.Fatalf("datasets=%d want 2", len(datasets))
|
||||||
|
}
|
||||||
|
if len(names) != 2 || names[0] != "FAN_A RPM" || names[1] != "FAN_B RPM" {
|
||||||
|
t.Fatalf("names=%v", names)
|
||||||
|
}
|
||||||
|
if len(labels) != 2 {
|
||||||
|
t.Fatalf("labels=%v want 2 entries", labels)
|
||||||
|
}
|
||||||
|
if labels[0] == "" || labels[1] == "" {
|
||||||
|
t.Fatalf("labels should contain timeline values, got %v", labels)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderNetworkInlineSyncsPendingState(t *testing.T) {
|
||||||
|
body := renderNetworkInline()
|
||||||
|
if !strings.Contains(body, "d.pending_change") {
|
||||||
|
t.Fatalf("network UI should read pending network state from API: %s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "setInterval(loadNetwork, 5000)") {
|
||||||
|
t.Fatalf("network UI should periodically refresh network state: %s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "showNetPending(NET_ROLLBACK_SECS)") {
|
||||||
|
t.Fatalf("network UI should show pending confirmation immediately on apply: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRootRendersDashboard(t *testing.T) {
|
func TestRootRendersDashboard(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
path := filepath.Join(dir, "audit.json")
|
path := filepath.Join(dir, "audit.json")
|
||||||
|
|||||||
@@ -9,6 +9,34 @@ All live metrics charts in the web UI are server-side SVG images served by Go
|
|||||||
and polled by the browser every 2 seconds via `<img src="...?t=now">`.
|
and polled by the browser every 2 seconds via `<img src="...?t=now">`.
|
||||||
There is no client-side canvas or JS chart library.
|
There is no client-side canvas or JS chart library.
|
||||||
|
|
||||||
|
## Rule: live charts must be visually uniform
|
||||||
|
|
||||||
|
Live charts are a single UI family, not a set of one-off widgets. New charts and
|
||||||
|
changes to existing charts must keep the same rendering model and presentation
|
||||||
|
rules unless there is an explicit architectural decision to diverge.
|
||||||
|
|
||||||
|
Default expectations:
|
||||||
|
|
||||||
|
- same server-side SVG pipeline for all live metrics charts
|
||||||
|
- same refresh behaviour and failure handling in the browser
|
||||||
|
- same canvas size class and card layout
|
||||||
|
- same legend placement policy across charts
|
||||||
|
- same axis, title, and summary conventions
|
||||||
|
- no chart-specific visual exceptions added as a quick fix
|
||||||
|
|
||||||
|
Current default for live charts:
|
||||||
|
|
||||||
|
- legend below the plot area when a chart has 8 series or fewer
|
||||||
|
- legend hidden when a chart has more than 8 series
|
||||||
|
- 10 equal Y-axis steps across the chart height
|
||||||
|
- 1400 x 360 SVG canvas with legend
|
||||||
|
- 1400 x 288 SVG canvas without legend
|
||||||
|
- full-width card rendering in a single-column stack
|
||||||
|
|
||||||
|
If one chart needs a different layout or legend behaviour, treat that as a
|
||||||
|
design-level decision affecting the whole chart family, not as a local tweak to
|
||||||
|
just one endpoint.
|
||||||
|
|
||||||
### Why go-analyze/charts
|
### Why go-analyze/charts
|
||||||
|
|
||||||
- Pure Go, no CGO — builds cleanly inside the live-build container
|
- Pure Go, no CGO — builds cleanly inside the live-build container
|
||||||
@@ -29,7 +57,8 @@ self-contained SVG renderer used **only** for completed SAT run reports
|
|||||||
| `GET /api/metrics/chart/server.svg` | CPU temp, CPU load %, mem load %, power W, fan RPMs |
|
| `GET /api/metrics/chart/server.svg` | CPU temp, CPU load %, mem load %, power W, fan RPMs |
|
||||||
| `GET /api/metrics/chart/gpu/{idx}.svg` | GPU temp °C, load %, mem %, power W |
|
| `GET /api/metrics/chart/gpu/{idx}.svg` | GPU temp °C, load %, mem %, power W |
|
||||||
|
|
||||||
Charts are 1400 × 280 px SVG. The page renders them at `width: 100%` in a
|
Charts are 1400 × 360 px SVG when the legend is shown, and 1400 × 288 px when
|
||||||
|
the legend is hidden. The page renders them at `width: 100%` in a
|
||||||
single-column layout so they always fill the viewport width.
|
single-column layout so they always fill the viewport width.
|
||||||
|
|
||||||
### Ring buffers
|
### Ring buffers
|
||||||
|
|||||||
Reference in New Issue
Block a user