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
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`;
|
|
}
|
|
|