Heracles/static/lib.js

584 lines
19 KiB
JavaScript
Raw Normal View History

2024-02-12 15:50:35 -06:00
// Copyright 2023 Jeremy Wall
2024-02-11 19:08:15 -06:00
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
2024-02-14 19:45:28 -06:00
/**
* @typedef PlotList
* @type {object}
2024-03-04 20:52:36 -05:00
* @property {Array=} Series
* @property {Array=} Scalar
* @property {Array<{timestamp: string, line: string}>=} StreamInstant - Timestamps are in seconds
* @property {Array<{timestamp: string, line: string}>=} Stream - Timestamps are in nanoseconds
*/
/**
* @typedef QueryData
* @type {object}
* @property {object} yaxes
* @property {?string} legend_orientation
* @property {Array<PlotList>} plots
*/
2024-03-04 20:52:36 -05:00
/**
* @typedef HeaderOrCell
* @type {object}
* @property {array} values
* @property {string=} fill
* @property {{width: number, color: string}=} line
* @property {{family: string, size: number, color: string }=} font
*/
/**
2024-03-04 20:52:36 -05:00
* @typedef TableTrace
* @type {object}
* @property {string=} name
* @property type {string}
* @property {string=} mode
* @property {HeaderOrCell} headers
* @property {HeaderOrCell} cells - An Array of columns for the table.
* @property {string=} xaxis
* @property {string=} yaxis
*/
/**
* @typedef GraphTrace
* @type {object}
* @property {string=} name
2024-03-04 20:52:36 -05:00
* @property {string=} fill
* @property type {string}
* @property {string=} mode
* @property {Array} x
* @property {Array} y
2024-03-04 20:52:36 -05:00
* @property {string=} xaxis
* @property {string=} yaxis
*/
/**
* @typedef PlotTrace
* @type {(TableTrace|GraphTrace)}
*/
/**
* Get's a css variable's value from the document.
* @param {string} variableName - Name of the variable to get `--var-name`
* @returns string
*/
2024-02-22 19:49:48 -05:00
function getCssVariableValue(variableName) {
return getComputedStyle(document.documentElement).getPropertyValue(variableName);
}
/**
* Custom element for showing a plotly graph.
*
* @extends HTMLElement
*/
2024-03-03 18:15:41 -05:00
export class GraphPlot extends HTMLElement {
/** @type {?string} */
2024-02-07 19:18:30 -06:00
#uri;
/** @type {?number} */
#width;
/** @type {?number} */
#height;
/** @type {?number} */
#intervalId;
/** @type {?number} */
#pollSeconds;
/** @type {?string} */
2024-02-14 19:45:28 -06:00
#end;
/** @type {?number} */
#duration;
/** @type {?string} */
#step_duration;
/** @type {?string} */
#d3TickFormat = "~s";
/** @type {?HTMLDivElement} */
#targetNode = null;
/** @type {?HTMLElement} */
2024-02-19 22:43:17 -05:00
#menuContainer = null;
/** @type {Object<string, HTMLSelectElement>} */
2024-02-19 22:43:17 -05:00
#filterSelectElements = {};
/** @type {Object<string, Array<string>>} */
2024-02-19 22:43:17 -05:00
#filterLabels = {};
/** @type {Object<string, Array<string>>} */
2024-02-19 22:43:17 -05:00
#filteredLabelSets = {};
constructor() {
super();
this.#width = 800;
this.#height = 600;
this.#pollSeconds = 30;
2024-02-19 22:43:17 -05:00
this.#menuContainer = this.appendChild(document.createElement('div'));
// TODO(jwall): These should probably be done as template clones so we have less places
// to look for class attributes.
this.#menuContainer.setAttribute("class", "row-flex");
this.#targetNode = this.appendChild(document.createElement("div"));
}
static observedAttributes = ['uri', 'width', 'height', 'poll-seconds', 'end', 'duration', 'step-duration', 'd3-tick-format'];
/**
* Callback for attributes changes.
*
* @param {string} name - The name of the attribute.
* @param {?string} _oldValue - The old value for the attribute
* @param {?string} newValue - The new value for the attribute
*/
2024-02-14 19:45:28 -06:00
attributeChangedCallback(name, _oldValue, newValue) {
switch (name) {
2024-02-12 16:22:57 -06:00
case 'uri':
this.#uri = newValue;
break;
2024-02-12 16:22:57 -06:00
case 'width':
this.#width = Number(newValue);
break;
2024-02-12 16:22:57 -06:00
case 'height':
this.#height = Number(newValue);
break;
2024-02-12 16:22:57 -06:00
case 'poll-seconds':
this.#pollSeconds = Number(newValue);
break;
2024-02-14 19:45:28 -06:00
case 'end':
this.#end = newValue;
break;
case 'duration':
this.#duration = Number(newValue);
break;
case 'step-duration':
this.#step_duration = newValue;
break;
case 'd3-tick-format':
this.#d3TickFormat = newValue;
break;
2024-02-12 16:22:57 -06:00
default: // do nothing;
break;
}
2024-02-19 22:43:17 -05:00
this.reset();
}
connectedCallback() {
this.#uri = this.getAttribute('uri') || this.#uri;
this.#width = Number(this.getAttribute('width') || this.#width);
this.#height = Number(this.getAttribute('height') || this.#height);
this.#pollSeconds = Number(this.getAttribute('poll-seconds') || this.#pollSeconds);
2024-02-14 19:45:28 -06:00
this.#end = this.getAttribute('end') || null;
this.#duration = Number(this.getAttribute('duration')) || null;
this.#step_duration = this.getAttribute('step-duration') || null;
this.#d3TickFormat = this.getAttribute('d3-tick-format') || this.#d3TickFormat;
2024-02-19 22:43:17 -05:00
this.reset();
}
disconnectedCallback() {
this.stopInterval()
2024-02-07 19:18:30 -06:00
}
static elementName = "graph-plot";
/*
* Get's the target node for placing the plotly graph.
*
* @returns {?HTMLDivElement}
*/
getTargetNode() {
return this.#targetNode;
}
2024-02-12 16:22:57 -06:00
/**
*/
stopInterval() {
if (this.#intervalId) {
clearInterval(this.#intervalId);
this.#intervalId = null;
}
}
2024-02-12 16:22:57 -06:00
/**
* Resets the entire graph and then restarts polling.
* @param {boolean=} updateOnly
*/
2024-02-19 22:43:17 -05:00
reset(updateOnly) {
var self = this;
self.stopInterval()
self.fetchData().then((data) => {
if (!updateOnly) {
self.getLabelsForData(data);
self.buildFilterMenu();
}
self.updateGraph(data).then(() => {
self.#intervalId = setInterval(() => self.updateGraph(), 1000 * self.#pollSeconds);
});
});
}
/** Registers the custom element if it doesn't already exist */
static registerElement() {
if (!customElements.get(GraphPlot.elementName)) {
customElements.define(GraphPlot.elementName, GraphPlot);
}
}
2024-02-12 16:22:57 -06:00
/**
* Returns the uri formatted with any query strings if necessary.
*
* @returns {string}
*/
getUri() {
2024-02-14 19:45:28 -06:00
if (this.#end && this.#duration && this.#step_duration) {
return this.#uri + "?end=" + this.#end + "&duration=" + this.#duration + "&step_duration=" + this.#step_duration;
} else {
return this.#uri;
}
}
/**
* Returns the data from an api call.
*
* @return {Promise<QueryData>}
*/
2024-02-07 19:18:30 -06:00
async fetchData() {
2024-02-19 22:43:17 -05:00
// TODO(zaphar): Can we do some massaging on these
// to get the full set of labels and possible values?
const response = await fetch(this.getUri());
2024-02-07 19:18:30 -06:00
const data = await response.json();
return data;
}
/**
* Formats the name for the plot trace.
* @param {{name_format: ?string}} meta
* @param {Map<string, string>} labels
* @return string
*/
formatName(meta, labels) {
var name = "";
const formatter = meta.name_format
if (formatter) {
name = eval(formatter);
} else {
var names = [];
for (const value of labels) {
names.push(value);
}
name = names.join(" ");
}
return name;
}
/**
* @param {Object<string, string>} labels
*/
2024-02-19 22:43:17 -05:00
populateFilterData(labels) {
for (var key in labels) {
const label = this.#filterLabels[key];
if (label) {
if (!label.includes(labels[key])) {
this.#filterLabels[key].push(labels[key]);
}
} else {
this.#filterLabels[key] = [labels[key]];
}
}
}
/**
* @param {string} key
* @returns {HTMLDivElement}
*/
2024-02-19 22:43:17 -05:00
buildSelectElement(key) {
2024-02-22 20:38:43 -05:00
// TODO(jwall): Should we have a select all?
2024-02-19 22:43:17 -05:00
var id = key + "-select" + Math.random();
const element = document.createElement("div");
const select = document.createElement("select");
select.setAttribute("name", id);
// TODO(jwall): This is how you set boolean attributes. Use the attribute named... :-(
select.setAttribute("multiple", "multiple");
2024-02-19 22:43:17 -05:00
const optElement = document.createElement("option");
const optValue = "Select " + key;
optElement.innerText = optValue;
select.appendChild(optElement);
for (var opt of this.#filterLabels[key]) {
const optElement = document.createElement("option");
optElement.setAttribute("value", opt);
optElement.setAttribute("selected", "selected");
2024-02-19 22:43:17 -05:00
optElement.innerText = opt;
select.appendChild(optElement);
}
2024-02-19 22:43:17 -05:00
var self = this;
select.onchange = function(evt) {
evt.stopPropagation();
var filteredValues = [];
for (var opt of /** @type {HTMLSelectElement} */(evt.target).selectedOptions) {
2024-02-19 22:43:17 -05:00
filteredValues.push(opt.getAttribute("value"));
}
self.#filteredLabelSets[key] = filteredValues;
self.reset(true);
};
element.appendChild(select);
return element;
}
buildFilterMenu() {
// We need to maintain a stable order for these
var children = [];
for (var key of Object.keys(this.#filterLabels).sort()) {
// If there are multiple items to filter by then show the selectElement.
// otherwise there is no point.
if (this.#filterLabels[key].length > 1) {
const element = this.#filterSelectElements[key] || this.buildSelectElement(key);
children.push(element);
}
2024-02-19 22:43:17 -05:00
}
this.#menuContainer.replaceChildren(...children);
}
/**
* @param {QueryData} graph
*/
2024-02-24 19:53:25 -05:00
getLabelsForData(graph) {
const data = graph.plots;
2024-02-19 22:43:17 -05:00
for (var subplot of data) {
if (subplot.Series) {
for (const triple of subplot.Series) {
const labels = triple[0];
this.populateFilterData(labels);
}
}
if (subplot.Scalar) {
for (const triple of subplot.Scalar) {
const labels = triple[0];
this.populateFilterData(labels);
}
}
2024-02-19 22:43:17 -05:00
}
}
2024-02-24 19:53:25 -05:00
yaxisNameGenerator() {
var counter = 1;
return function() {
var name = "yaxis";
if (counter != 1) {
name = "yaxis" + counter;
2024-02-24 19:53:25 -05:00
}
counter++;
return name;
};
}
/**
* Update the graph with new data.
*
* @param {?QueryData=} maybeGraph
*/
2024-02-24 19:53:25 -05:00
async updateGraph(maybeGraph) {
var graph = maybeGraph;
if (!graph) {
graph = await this.fetchData();
2024-02-19 22:43:17 -05:00
}
2024-02-24 19:53:25 -05:00
var data = graph.plots;
var yaxes = graph.yaxes;
2024-02-16 17:23:11 -05:00
var layout = {
displayModeBar: false,
responsive: true,
2024-02-22 19:49:48 -05:00
plot_bgcolor: getCssVariableValue('--paper-background-color').trim(),
paper_bgcolor: getCssVariableValue('--paper-background-color').trim(),
font: {
color: getCssVariableValue('--text-color').trim()
2024-02-22 20:19:54 -05:00
},
xaxis: {
gridcolor: getCssVariableValue("--accent-color")
},
legend: {
orientation: 'v'
2024-02-22 19:49:48 -05:00
}
};
if (graph.legend_orientation) {
layout.legend.orientation = graph.legend_orientation;
}
2024-02-24 19:53:25 -05:00
var nextYaxis = this.yaxisNameGenerator();
for (const yaxis of yaxes) {
yaxis.tickformat = yaxis.tickformat || this.#d3TickFormat;
yaxis.gridColor = getCssVariableValue("--accent-color");
layout[nextYaxis()] = yaxis;
}
var traces = /** @type {Array<PlotTrace>} */ ([]);
2024-02-16 17:23:11 -05:00
for (var subplot_idx in data) {
const subplot = data[subplot_idx];
2024-02-24 19:53:25 -05:00
var nextYaxis = this.yaxisNameGenerator();
2024-02-16 17:23:11 -05:00
if (subplot.Series) {
// https://plotly.com/javascript/reference/scatter/
2024-02-19 22:43:17 -05:00
loopSeries: for (const triple of subplot.Series) {
2024-02-16 17:23:11 -05:00
const labels = triple[0];
2024-02-19 22:43:17 -05:00
for (var label in labels) {
var show = this.#filteredLabelSets[label];
if (show && !show.includes(labels[label])) {
continue loopSeries;
}
}
2024-02-16 17:23:11 -05:00
const meta = triple[1];
2024-02-24 19:53:25 -05:00
var yaxis = meta.yaxis || "y";
// https://plotly.com/javascript/reference/layout/yaxis/
2024-02-16 17:23:11 -05:00
const series = triple[2];
2024-03-04 20:52:36 -05:00
const trace = /** @type GraphTrace */({
2024-02-16 17:23:11 -05:00
type: "scatter",
mode: "lines+text",
x: [],
y: [],
// We always share the x axis for timeseries graphs.
xaxis: "x",
2024-02-16 17:23:11 -05:00
yaxis: yaxis,
2024-02-24 19:53:25 -05:00
//yhoverformat: yaxis.tickformat,
2024-03-04 20:52:36 -05:00
});
if (meta.fill) {
trace.fill = meta.fill;
}
var name = this.formatName(meta, labels);
2024-02-16 17:23:11 -05:00
if (name) { trace.name = name; }
for (const point of series) {
trace.x.push(new Date(point.timestamp * 1000));
trace.y.push(point.value);
}
traces.push(trace);
}
} else if (subplot.Scalar) {
// https://plotly.com/javascript/reference/bar/
loopScalar: for (const triple of subplot.Scalar) {
2024-02-16 17:23:11 -05:00
const labels = triple[0];
for (var label in labels) {
var show = this.#filteredLabelSets[label];
if (show && !show.includes(labels[label])) {
continue loopScalar;
}
}
2024-02-16 17:23:11 -05:00
const meta = triple[1];
const series = triple[2];
2024-03-04 20:52:36 -05:00
const trace = /** @type GraphTrace */({
2024-02-16 17:23:11 -05:00
type: "bar",
x: [],
y: [],
yhoverformat: meta["d3_tick_format"],
});
var name = this.formatName(meta, labels);
2024-02-19 19:20:05 -05:00
if (name) { trace.name = name; }
2024-02-16 17:23:11 -05:00
trace.y.push(series.value);
2024-02-19 19:20:05 -05:00
trace.x.push(trace.name);
2024-02-16 17:23:11 -05:00
traces.push(trace);
2024-02-07 19:18:30 -06:00
}
2024-03-04 20:52:36 -05:00
} else if (subplot.Stream) {
// TODO(zaphar): subplot.Stream // log lines!!!
const trace = /** @type TableTrace */({
type: "table",
headers: {
align: "left",
values: ["Timestamp", "Log"]
},
cells: {
align: "left",
values: []
},
});
const dateColumn = [];
const logColumn = [];
loopStream: for (const pair of subplot.Stream) {
const labels = pair[0];
for (var label in labels) {
var show = this.#filteredLabelSets[label];
if (show && !show.includes(labels[label])) {
continue loopStream;
}
}
const lines = pair[1];
// TODO(jwall): Headers
for (const line of lines) {
// For streams the timestamps are in nanoseconds
dateColumn.push(new Date(line.timestamp / 1000000));
logColumn.push(line.line);
}
}
trace.cells.values.push(dateColumn);
trace.cells.values.push(logColumn);
traces.push(trace);
}
2024-02-07 19:18:30 -06:00
}
2024-02-16 17:23:11 -05:00
// https://plotly.com/javascript/plotlyjs-function-reference/#plotlyreact
// @ts-ignore
Plotly.react(this.getTargetNode(), traces, layout, null);
2024-02-07 19:18:30 -06:00
}
}
GraphPlot.registerElement();
2024-02-14 19:45:28 -06:00
/** Custom Element for selecting a timespan for the dashboard. */
2024-03-03 18:15:41 -05:00
export class SpanSelector extends HTMLElement {
/** @type {HTMLElement} */
2024-02-14 19:45:28 -06:00
#targetNode = null;
/** @type {HTMLInputElement} */
2024-02-14 19:45:28 -06:00
#endInput = null;
/** @type {HTMLInputElement} */
2024-02-14 19:45:28 -06:00
#durationInput = null;
/** @type {HTMLInputElement} */
2024-02-14 19:45:28 -06:00
#stepDurationInput = null;
/** @type {HTMLButtonElement} */
2024-02-14 19:45:28 -06:00
#updateInput = null
2024-02-16 17:23:11 -05:00
2024-02-14 19:45:28 -06:00
constructor() {
super();
this.#targetNode = this.appendChild(document.createElement('div'));
2024-02-16 17:23:11 -05:00
2024-02-14 19:45:28 -06:00
this.#targetNode.appendChild(document.createElement('span')).innerText = "end: ";
this.#endInput = this.#targetNode.appendChild(document.createElement('input'));
2024-02-16 17:23:11 -05:00
2024-02-14 19:45:28 -06:00
this.#targetNode.appendChild(document.createElement('span')).innerText = "duration: ";
this.#durationInput = this.#targetNode.appendChild(document.createElement('input'));
2024-02-16 17:23:11 -05:00
2024-02-14 19:45:28 -06:00
this.#targetNode.appendChild(document.createElement('span')).innerText = "step duration: ";
this.#stepDurationInput = this.#targetNode.appendChild(document.createElement('input'));
2024-02-16 17:23:11 -05:00
2024-02-14 19:45:28 -06:00
this.#updateInput = this.#targetNode.appendChild(document.createElement('button'));
this.#updateInput.innerText = "Update";
}
connectedCallback() {
const self = this;
2024-02-22 20:19:54 -05:00
// TODO(jwall): We should probably show a loading indicator of some kind.
2024-02-14 19:45:28 -06:00
self.#updateInput.onclick = function(_evt) {
self.updateGraphs()
};
}
2024-02-16 17:23:11 -05:00
2024-02-14 19:45:28 -06:00
disconnectedCallback() {
this.#updateInput.onclick = undefined;
}
/** Updates all the graphs on the dashboard with the new timespan. */
2024-02-14 19:45:28 -06:00
updateGraphs() {
for (var node of document.getElementsByTagName(GraphPlot.elementName)) {
2024-02-14 19:45:28 -06:00
node.setAttribute('end', this.#endInput.value);
node.setAttribute('duration', this.#durationInput.value);
node.setAttribute('step-duration', this.#stepDurationInput.value);
}
}
static elementName = "span-selector";
/** Register the element if it doesn't exist */
2024-02-14 19:45:28 -06:00
static registerElement() {
if (!customElements.get(SpanSelector.elementName)) {
customElements.define(SpanSelector.elementName, SpanSelector);
}
}
}
SpanSelector.registerElement();
2024-03-03 18:15:41 -05:00