Files
seaweedfs/telemetry/server/dashboard/dashboard.go
T
Chris LuandGitHub 5532a316c5 telemetry: put the version pie back beside the stacked chart (#10626)
* telemetry: put the version pie back, beside the stacked chart

The two answer different questions and the pie was the better answer to one
of them: what the fleet is on right now, at a glance. Restore it under its
old name and give the stack its own card as Versions Over Time, so the pie
is the last day of the chart below it.

* telemetry: draw the distribution pies at the size of their cards

Both pies kept the canvas tag's 2:1 ratio at the card's full width, so they
came out around 560px tall and spilled past the card they sit in. Give them a
height to fill instead, and build every chart after the dashboard is shown: a
canvas in a display:none container measures zero, and a pie sized from that
never grows back.
2026-08-07 12:48:28 -07:00

564 lines
23 KiB
Go

package dashboard
import (
"net/http"
)
type Handler struct{}
func NewHandler() *Handler {
return &Handler{}
}
func (h *Handler) ServeIndex(w http.ResponseWriter, r *http.Request) {
html := `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SeaweedFS Telemetry Dashboard</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
margin: 0;
padding: 20px;
background-color: #f5f5f5;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
.header {
background: white;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
margin-bottom: 20px;
}
.stat-card {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.stat-value {
font-size: 2em;
font-weight: bold;
color: #2196F3;
}
.stat-label {
color: #666;
margin-top: 5px;
}
.chart-container {
background: white;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.chart-title {
font-size: 1.2em;
font-weight: bold;
margin-bottom: 15px;
}
.chart-subtitle {
color: #666;
margin: -10px 0 15px;
}
.loading {
text-align: center;
padding: 40px;
color: #666;
}
.cluster-lookup {
display: flex;
gap: 10px;
margin-bottom: 15px;
}
.cluster-lookup input {
flex: 1;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-family: monospace;
}
.cluster-lookup button {
padding: 8px 20px;
border: none;
border-radius: 4px;
background: #2196F3;
color: white;
cursor: pointer;
}
.error {
background: #ffebee;
color: #c62828;
padding: 15px;
border-radius: 4px;
margin: 10px 0;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>SeaweedFS Telemetry Dashboard</h1>
<p>Privacy-respecting usage analytics for SeaweedFS</p>
</div>
<div id="loading" class="loading">Loading telemetry data...</div>
<div id="error" class="error" style="display: none;"></div>
<div id="dashboard" style="display: none;">
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value" id="totalInstances">-</div>
<div class="stat-label">Total Instances (30 days)</div>
</div>
<div class="stat-card">
<div class="stat-value" id="activeInstances">-</div>
<div class="stat-label">Active Instances (7 days)</div>
</div>
<div class="stat-card">
<div class="stat-value" id="confirmedInstances">-</div>
<div class="stat-label">Confirmed Clusters (2+ days)</div>
</div>
<div class="stat-card">
<div class="stat-value" id="totalVersions">-</div>
<div class="stat-label">Different Versions</div>
</div>
<div class="stat-card">
<div class="stat-value" id="totalOS">-</div>
<div class="stat-label">Operating Systems</div>
</div>
</div>
<div class="chart-container">
<div class="chart-title">Version Distribution</div>
<div style="position: relative; height: 320px;">
<canvas id="versionChart"></canvas>
</div>
</div>
<div class="chart-container">
<div class="chart-title">Versions Over Time</div>
<div class="chart-subtitle" id="versionSeriesTotal"></div>
<div style="position: relative; height: 420px;">
<canvas id="versionSeriesChart"></canvas>
</div>
</div>
<div class="chart-container">
<div class="chart-title">Operating System Distribution</div>
<div style="position: relative; height: 320px;">
<canvas id="osChart"></canvas>
</div>
</div>
<div class="chart-container">
<div class="chart-title">Cluster Sizes Over Time</div>
<div class="chart-subtitle" id="clusterSizesTotal"></div>
<div style="position: relative; height: 420px;">
<canvas id="clusterSizeChart"></canvas>
</div>
</div>
<div class="chart-container">
<div class="chart-title">Volume Servers Over Time</div>
<div class="chart-subtitle" id="clusterServersTotal"></div>
<div style="position: relative; height: 420px;">
<canvas id="serverChart"></canvas>
</div>
</div>
<div class="chart-container">
<div class="chart-title">Per-Cluster History</div>
<div class="cluster-lookup">
<input type="text" id="clusterIdInput" placeholder="cluster UUID"
onkeydown="if (event.key === 'Enter') loadClusterHistory()">
<button onclick="loadClusterHistory()">Load</button>
</div>
<div id="clusterHistoryError" class="error" style="display: none;"></div>
<div id="clusterHistoryCharts" style="display: none;">
<canvas id="clusterDiskChart" width="400" height="150"></canvas>
<canvas id="clusterVolumeChart" width="400" height="150"></canvas>
</div>
</div>
</div>
</div>
<script>
let charts = {};
let clusterSizeIds = [];
async function loadDashboard() {
try {
// Load stats
const statsResponse = await fetch('/api/stats');
const stats = await statsResponse.json();
// Load per-cluster sizes over time
const sizesResponse = await fetch('/api/cluster-sizes?days=30&limit=20');
const sizes = await sizesResponse.json();
// Load the fleet's version make-up over time
const versionsResponse = await fetch('/api/versions?days=30&limit=8');
const versions = await versionsResponse.json();
// Show the dashboard before drawing into it: a canvas in a
// display:none container measures zero, and a pie sized from
// that never grows back.
document.getElementById('loading').style.display = 'none';
document.getElementById('dashboard').style.display = 'block';
updateStats(stats);
updateCharts(stats);
updateVersions(versions);
updateClusterSizes(sizes);
} catch (error) {
console.error('Error loading dashboard:', error);
showError('Failed to load telemetry data: ' + error.message);
}
}
function updateStats(stats) {
document.getElementById('totalInstances').textContent = stats.total_instances || 0;
document.getElementById('activeInstances').textContent = stats.active_instances || 0;
document.getElementById('confirmedInstances').textContent = stats.confirmed_instances || 0;
document.getElementById('totalVersions').textContent = Object.keys(stats.versions || {}).length;
document.getElementById('totalOS').textContent = Object.keys(stats.os_distribution || {}).length;
}
function updateCharts(stats) {
createPieChart('versionChart', 'Version Distribution', stats.versions || {});
createPieChart('osChart', 'Operating System Distribution', stats.os_distribution || {});
}
function createPieChart(canvasId, title, data) {
const ctx = document.getElementById(canvasId).getContext('2d');
if (charts[canvasId]) {
charts[canvasId].destroy();
}
const labels = Object.keys(data);
const values = Object.values(data);
charts[canvasId] = new Chart(ctx, {
type: 'pie',
data: {
labels: labels,
datasets: [{
data: values,
backgroundColor: [
'#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0',
'#9966FF', '#FF9F40', '#FF6384', '#C9CBCF'
]
}]
},
options: {
responsive: true,
// Without this the canvas keeps its 2:1 attribute ratio at
// the card's full width, drawing a pie taller than the card.
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom'
}
}
}
});
}
function createLineChart(canvasId, label, labels, data, color) {
const ctx = document.getElementById(canvasId).getContext('2d');
if (charts[canvasId]) {
charts[canvasId].destroy();
}
charts[canvasId] = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [{
label: label,
data: data,
borderColor: color,
backgroundColor: color + '20',
fill: true,
tension: 0.1
}]
},
options: {
responsive: true,
scales: {
y: {
beginAtZero: true
}
}
}
});
}
// Decimal units, so the round numbers the chart picks for its ticks
// come out as round labels.
function formatBytes(bytes) {
const units = ['B', 'kB', 'MB', 'GB', 'TB', 'PB'];
let value = bytes || 0, unit = 0;
while (value >= 1000 && unit < units.length - 1) {
value /= 1000;
unit++;
}
return (unit === 0 ? value : value.toFixed(value >= 100 ? 0 : 1)) + ' ' + units[unit];
}
// Fixed hues rather than the evenly spaced ones the cluster stacks use:
// a handful of versions is a set you read, and evenly spaced hues put
// pairs next to each other that colour-blind readers can't separate.
const versionColors = ['#2a78d6', '#eb6834', '#1baf7a', '#eda100',
'#e87ba4', '#008300', '#4a3aa7', '#e34948'];
// One stacked band per version: the band is how many clusters ran that
// version that day, the top of the stack is the confirmed fleet, so the
// chart shows both how it grows and what it upgrades to. The pie above
// it is the same fleet on the last of these days.
function updateVersions(series) {
const versions = series.versions || [];
const dates = series.dates || [];
const total = series.total_clusters || 0;
document.getElementById('versionSeriesTotal').textContent =
total + ' cluster' + (total === 1 ? '' : 's') + ' on ' + (dates[dates.length - 1] || 'no data');
// Newest release on the floor, oldest on top: the current release
// is the band being read, and one anchored to the baseline reads
// straight off the axis instead of riding on everything below it.
// Colours follow the same order, so a release keeps its colour as
// older ones age out from the top.
const datasets = versions.slice().reverse().map((v, i) =>
band(v.version, v.clusters, '#ffffff', versionColors[i % versionColors.length], 2));
if (series.other) {
datasets.push(band('other (' + series.other.count + ' versions)', series.other.clusters,
'#ffffff', '#9a9a94', 2));
}
stackedArea('versionSeriesChart', dates, datasets, value => value, { plugins: [bandLabels] });
}
// Writes each version into its own band, so the chart reads without
// matching colours against the legend. Bands too thin to hold the text
// keep it, and the halo carries it over whatever it crosses.
const bandLabels = {
id: 'bandLabels',
afterDatasetsDraw(chart) {
const ctx = chart.ctx;
ctx.save();
ctx.font = '600 12px -apple-system, BlinkMacSystemFont, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.lineJoin = 'round';
chart.data.datasets.forEach((dataset, d) => {
const meta = chart.getDatasetMeta(d);
if (meta.hidden) return;
// Label where the band is thickest, so the text has room.
let at = -1, thickest = 0;
dataset.data.forEach((value, i) => {
if (value > thickest) {
thickest = value;
at = i;
}
});
const point = at >= 0 && meta.data[at];
if (!point) return;
const below = d === 0 ? chart.scales.y.getPixelForValue(0)
: chart.getDatasetMeta(d - 1).data[at].y;
if (below - point.y < 18) return;
ctx.lineWidth = 3;
ctx.strokeStyle = 'rgba(255, 255, 255, 0.85)';
ctx.strokeText(dataset.label, point.x, (point.y + below) / 2);
ctx.fillStyle = '#1a1a1a';
ctx.fillText(dataset.label, point.x, (point.y + below) / 2);
});
ctx.restore();
}
};
// Disk usage and volume servers both drawn as one stacked band per
// cluster over time: the band is that cluster's share, the top of the
// stack is the fleet total. Clusters beyond the requested limit are
// summed into a trailing "other" band so the stack still adds up.
function updateClusterSizes(series) {
const clusters = series.clusters || [];
const dates = series.dates || [];
const count = series.cluster_count || 0;
const servers = series.total_servers || 0;
const across = ' across ' + count + ' cluster' + (count === 1 ? '' : 's') +
' on ' + (dates[dates.length - 1] || 'no data');
document.getElementById('clusterSizesTotal').textContent = formatBytes(series.total_disk) + across;
document.getElementById('clusterServersTotal').textContent =
servers + ' volume server' + (servers === 1 ? '' : 's') + across;
clusterSizeIds = clusters.map(c => c.cluster_id);
if (series.other) {
clusterSizeIds.push(null);
}
stackedChart('clusterSizeChart', dates, clusters, series.other, 'disk', formatBytes);
stackedChart('serverChart', dates, clusters, series.other, 'servers', value => value);
}
// The stacks share one cluster order, so a cluster keeps its colour and
// its legend entry across both of them.
function stackedChart(canvasId, dates, clusters, other, key, format) {
const datasets = clusters.map((c, i) => {
// Evenly spaced hues keep neighbouring bands distinguishable.
const hue = Math.round(i * 360 / clusters.length);
return band(c.cluster_id.slice(0, 8), c[key],
'hsl(' + hue + ', 65%, 45%)', 'hsla(' + hue + ', 65%, 55%, 0.75)');
});
if (other) {
datasets.push(band('other (' + other.count + ')', other[key],
'#9E9E9E', 'rgba(158, 158, 158, 0.6)'));
}
stackedArea(canvasId, dates, datasets, format, {
onClick: (event, elements) => {
const id = elements.length && clusterSizeIds[elements[0].datasetIndex];
if (id) {
document.getElementById('clusterIdInput').value = id;
loadClusterHistory();
}
},
// The legend shows shortened ids; the tooltip has room for the
// full one to paste into the lookup box.
label: item => (clusterSizeIds[item.datasetIndex] || item.dataset.label) + ': ' + format(item.raw)
});
}
// Draws the bands as one stack, so their heights add up to the day's
// total. hooks.onClick, hooks.label and hooks.plugins are optional.
function stackedArea(canvasId, dates, datasets, format, hooks) {
hooks = hooks || {};
const ctx = document.getElementById(canvasId).getContext('2d');
if (charts[canvasId]) {
charts[canvasId].destroy();
}
charts[canvasId] = new Chart(ctx, {
type: 'line',
data: { labels: dates, datasets: datasets },
plugins: hooks.plugins,
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'band', intersect: false },
onClick: hooks.onClick,
plugins: {
legend: { position: 'bottom', labels: { boxWidth: 12, font: { size: 11 } } },
tooltip: {
callbacks: {
label: hooks.label || (item => item.dataset.label + ': ' + format(item.raw))
}
}
},
scales: {
x: { ticks: { maxRotation: 0, autoSkip: true, maxTicksLimit: 12 } },
y: {
stacked: true,
beginAtZero: true,
ticks: { precision: 0, callback: value => format(value) }
}
}
}
});
}
// Hover and click resolve to the band the pointer is inside. Chart.js's
// built-in modes match the nearest line, which on a stack of thin bands
// is rarely the one under the pointer.
Chart.Interaction.modes.band = function(chart, event) {
const last = chart.data.labels.length - 1;
if (last < 0) return [];
const index = Math.min(Math.max(Math.round(chart.scales.x.getValueForPixel(event.x)), 0), last);
const value = chart.scales.y.getValueForPixel(event.y);
let stacked = 0;
for (let d = 0; d < chart.data.datasets.length; d++) {
stacked += chart.data.datasets[d].data[index] || 0;
if (value <= stacked) {
return [{ element: chart.getDatasetMeta(d).data[index], datasetIndex: d, index: index }];
}
}
return [];
};
function band(label, data, borderColor, backgroundColor, borderWidth) {
return {
label: label,
data: data,
borderColor: borderColor,
backgroundColor: backgroundColor,
borderWidth: borderWidth || 1,
pointRadius: 0,
pointHitRadius: 8,
fill: true,
tension: 0.1
};
}
async function loadClusterHistory() {
const id = document.getElementById('clusterIdInput').value.trim();
const errorDiv = document.getElementById('clusterHistoryError');
const chartsDiv = document.getElementById('clusterHistoryCharts');
if (!id) return;
errorDiv.style.display = 'none';
try {
const resp = await fetch('/api/history?cluster_id=' + encodeURIComponent(id) + '&days=90');
if (!resp.ok) {
throw new Error(resp.status === 404 ? 'Unknown cluster UUID' : 'Request failed: ' + resp.status);
}
const history = await resp.json();
const samples = history.samples || [];
if (samples.length === 0) {
throw new Error('No samples recorded for this cluster yet');
}
const dates = samples.map(s => new Date(s.ts * 1000).toISOString().slice(0, 10));
chartsDiv.style.display = 'block';
createLineChart('clusterDiskChart', 'Disk Usage (GB)', dates,
samples.map(s => Math.round(s.disk / (1024 * 1024 * 1024) * 100) / 100), '#FF9800');
createLineChart('clusterVolumeChart', 'Volumes', dates,
samples.map(s => s.volumes), '#9C27B0');
} catch (error) {
chartsDiv.style.display = 'none';
errorDiv.style.display = 'block';
errorDiv.textContent = error.message;
}
}
function showError(message) {
document.getElementById('loading').style.display = 'none';
document.getElementById('error').style.display = 'block';
document.getElementById('error').textContent = message;
}
// Load dashboard on page load
loadDashboard();
// Refresh every 5 minutes
setInterval(loadDashboard, 5 * 60 * 1000);
</script>
</body>
</html>`
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusOK)
w.Write([]byte(html))
}