generates graphs on a webpage from RRD files (using yeri)
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

275 lines
8.4 KiB

import { GraphInstant, RrdFile, SvgGraph, TimeSeries, TimeSeriesList } from "yeri";
import { fetchXHR } from "./fetch";
const SECOND = 1000;
const MINUTE = 60 * SECOND;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
const WEEK = 7 * DAY;
const DEFAULT_ERROR_COLOR = "#ff3333";
const DEFAULT_ERROR_OPACITY = "0.3";
const TOOLTIP_HEADER = `<div>%title%</div>`;
const TOOLTIP_ROW = `
<div class="netgraph-tooltip-row">
<div class="netgraph-tooltip-box" style="background: %color%"></div>
<div class="netgraph-tooltip-value">%value%</div>
</div>
`;
// add our own css block to the page, which you can override in the HTML.
const INJECTED_CSS = `
.netgraph-tooltip {
position: absolute;
font-family: monospace;
border: solid 2px #aaa;
border-radius: 0.5em;
background: #eee;
padding: 1em;
display: none;
}
.netgraph-tooltip-row {
display: flex;
flex-flow: row nowrap;
align-items: baseline;
}
.netgraph-tooltip-box {
width: 0.75em;
height: 0.75em;
border: solid 1px black;
display: inline-block;
margin-right: 0.5em;
}
.netgraph-tooltip-value {
flex: 1;
text-align: left;
}
`;
interface RrdData {
rrd?: RrdFile;
key: string;
}
const rrds = new Map<string, RrdData>();
interface GraphOptions {
unit: string;
maxValue?: number;
fill: boolean;
colors?: string[];
errorColor: string;
errorOpacity: number;
// turn on/off features
showHighlight: boolean;
showTooltip: boolean;
}
window.addEventListener("DOMContentLoaded", async () => {
const now = Date.now();
const head = document.getElementsByTagName("head")[0];
if (head) {
const style = document.createElement("style");
style.setAttribute("type", "text/css");
style.media = "screen";
style.innerHTML = INJECTED_CSS;
head.prepend(style);
} else {
console.log("*** Page has no <head> so there will be no styling.");
}
// load any RRD
const rrdElements = [...document.querySelectorAll("rrd")];
const paths = new Map(rrdElements.map(elem => [ elem.getAttribute("path"), elem.getAttribute("timeout") ]));
const rrdFiles = new Map<string, RrdFile>();
await Promise.all([...paths].map(async ([ path, timeout ]) => {
try {
if (!path) throw new Error("Missing rrd path");
const data = await fetchXHR(path, timeout ? parseInt(timeout, 10) : undefined);
rrdFiles.set(path, new RrdFile(new DataView(data)));
} catch (error) {
console.log("Failed to load: " + path);
}
}));
rrdElements.forEach(elem => {
rrds.set(elem.getAttribute("name") ?? "", {
rrd: rrdFiles.get(elem.getAttribute("path") ?? ""),
key: elem.getAttribute("data") ?? ""
});
});
[...document.querySelectorAll(".netgraph-group")].forEach(async div => {
[...div.querySelectorAll(".netgraph")].forEach(async graphDiv => {
function hasAttr(name: string): boolean {
return graphDiv.hasAttribute(name) || div.hasAttribute(name);
}
function getAttr(name: string, defaultValue: string): string {
return graphDiv.getAttribute(name) ?? div.getAttribute(name) ?? defaultValue;
}
let lines = getAttr("line", "").split(/\s*,\s*/).filter(x => x.length > 0);
let errors = getAttr("error", "").split(/\s*,\s*/).filter(x => x.length > 0);
let errorThreshold = parseFloat(getAttr("error-threshold", "0.0"));
let endTime = new Date(getAttr("time", new Date(now).toISOString())).getTime();
const timespan = getAttr("timespan", "hour");
let startTime = endTime;
switch (timespan) {
case "week":
startTime = endTime - WEEK;
break;
case "day":
startTime = endTime - DAY;
break;
case "hour":
default:
startTime = endTime - HOUR;
break;
}
const graphOptions: GraphOptions = {
unit: getAttr("unit", "ms"),
maxValue: hasAttr("max") ? parseInt(getAttr("max", ""), 10) : undefined,
fill: hasAttr("fill"),
colors: hasAttr("colors") ? getAttr("colors", "").split(/\s*,\s*/) : undefined,
errorColor: getAttr("error-color", DEFAULT_ERROR_COLOR),
errorOpacity: parseFloat(getAttr("error-opacity", DEFAULT_ERROR_OPACITY)),
showHighlight: !hasAttr("no-highlight"),
showTooltip: !hasAttr("no-tooltip"),
};
await makeGraph(graphDiv as HTMLElement, lines, errors, errorThreshold, startTime, endTime, graphOptions);
});
});
});
async function makeGraph(
div: HTMLElement,
lines: string[],
errors: string[],
errorThreshold: number,
startTime: number,
endTime: number,
graphOptions: GraphOptions
): Promise<void> {
// construct lines.
let incomplete = false;
const data = new TimeSeriesList(lines.map(name => {
const rrdData = rrds.get(name);
if (!rrdData || !rrdData.rrd) {
console.log("*** No RRD data for key: " + name);
incomplete = true;
return TimeSeries.fromArrays(rrds.get(name)!.key, [], []);
}
return rrdData.rrd.getTimeSeries(rrdData.key, startTime / 1000, endTime / 1000);
}));
// fetch errors.
const errorData = new TimeSeriesList(errors.map(name => {
const rrdData = rrds.get(name);
if (!rrdData || !rrdData.rrd) {
console.log("*** No RRD data for key: " + name);
incomplete = true;
return TimeSeries.fromArrays(rrds.get(name)!.key, [], []);
}
return rrdData.rrd.getTimeSeries(rrdData.key, startTime / 1000, endTime / 1000);
}));
const width = div.clientWidth;
const height = div.clientHeight;
const graph = new SvgGraph(data, {
graphBackgroundColor: incomplete ? "#ffeeee" : "#ffffff",
showLegend: false,
showTopYLabel: false,
showBottomYLabel: false,
aspectRatio: width / height,
fill: graphOptions.fill,
colors: graphOptions.colors,
maxY: graphOptions.maxValue,
padding: 0,
fontSize: 18,
yAxisLabelFormat: n => `${Math.round(n * 100) / 100}${graphOptions.unit}`,
highlights: errorData.list.map(ts => {
return {
color: graphOptions.errorColor,
opacity: graphOptions.errorOpacity,
threshold: (timestamp, _graph) => (ts.interpolate(timestamp) ?? 0) > errorThreshold,
};
}),
});
const img = document.createElement("img");
img.style.width = "100%";
img.style.height = "100%";
img.src = "data:image/svg+xml;base64," + btoa(graph.draw());
while (div.firstChild) div.removeChild(div.firstChild);
div.appendChild(img);
if (graphOptions.showHighlight) {
const tooltip = document.createElement("div");
tooltip.className = "netgraph-tooltip";
div.appendChild(tooltip);
img.addEventListener("mousemove", e => {
const xFrac = e.offsetX / img.clientWidth;
const yFrac = e.offsetY / img.clientHeight;
const instant = graph.nearestToMouse(xFrac, yFrac);
graph.setFocus(instant);
img.src = "data:image/svg+xml;base64," + btoa(graph.draw());
if (!instant) {
tooltip.style.display = "none";
return;
}
if (graphOptions.showTooltip) drawToolTip(div, tooltip, instant, graph, graphOptions);
});
img.addEventListener("mouseleave", e => {
tooltip.style.display = "none";
graph.setFocus();
img.src = "data:image/svg+xml;base64," + btoa(graph.draw());
});
}
}
function drawToolTip(
div: HTMLElement,
tooltip: HTMLElement,
instant: GraphInstant,
graph: SvgGraph,
graphOptions: GraphOptions,
) {
const graphBox = graph.getMouseGraphBox();
const midY = div.offsetTop + (graphBox.y + graphBox.height / 2) * div.clientHeight;
const xl = div.offsetLeft + (graphBox.x + graphBox.width * 0.25) * div.clientWidth;
const xr = div.offsetLeft + (graphBox.x + graphBox.width * 0.75) * div.clientWidth;
const x = instant.xPercent < 0.5 ? xr : xl;
const rowData = instant.values.map((v, i) => [ v, i ]) as [ number | undefined, number ][];
rowData.sort(([ v1, _1 ], [ v2, _2 ]) => (v2 ?? 0) - (v1 ?? 0));
const title = graph.formatTimestamp(instant.timestamp, "L/dd HH:mm");
const header = TOOLTIP_HEADER.replace("%title%", title);
const rows = rowData.filter(([ v, _i ]) => v !== undefined).map(([ v, i ]) => {
return TOOLTIP_ROW
.replace("%color%", graph.config.colors[i])
.replace("%value%", v === undefined ? "(none)" : (Math.round(v * 100) / 100).toString() + graphOptions.unit);
});
tooltip.innerHTML = [ header, ...rows ].join("\n");
tooltip.style.display = "block";
tooltip.style.left = `${x - tooltip.clientWidth / 2}px`;
tooltip.style.top = `${midY - tooltip.clientHeight / 2}px`;
}