From 9c904a3c62ce170faa880bb3362390cc0b53a5e5 Mon Sep 17 00:00:00 2001 From: Jeremy Wall Date: Sun, 26 May 2024 19:34:13 -0400 Subject: [PATCH] refactor: Split the log and graph elements --- src/routes.rs | 2 +- static/lib.mjs | 664 ++++++++++++++++++++++++++++--------------------- 2 files changed, 383 insertions(+), 283 deletions(-) diff --git a/src/routes.rs b/src/routes.rs index 20909d1..aeedf22 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -154,7 +154,7 @@ pub fn log_component(dash_idx: usize, log_idx: usize, log: &LogStream) -> Markup html! { div { h2 { (log.title) " - " a href=(log_embed_uri) { "embed url" } } - graph-plot uri=(log_data_uri) id=(log_id) { } + log-plot uri=(log_data_uri) id=(log_id) { } } } } diff --git a/static/lib.mjs b/static/lib.mjs index 5b4c35e..ccaf8eb 100644 --- a/static/lib.mjs +++ b/static/lib.mjs @@ -12,6 +12,19 @@ // See the License for the specific language governing permissions and // limitations under the License. + +function yaxisNameGenerator() { + var counter = 1; + return function() { + var name = "yaxis"; + if (counter != 1) { + name = "yaxis" + counter; + } + counter++; + return name; + }; +} + /** * Map ansi terminal codes to html color codes. * @param {string} line @@ -33,7 +46,7 @@ function ansiToHtml(line) { // NOTE(zaphar): Yes this is gross and I should really do a better parser but I'm lazy. // Replace ANSI codes with HTML span elements styled with the corresponding color - return line.replace(/\x1b\[([0-9;]*)m/g, (match, p1) => { + return line.replace(/\x1b\[([0-9;]*)m/g, (_match, p1) => { const parts = p1.split(';'); // ANSI codes can be compounded, e.g., "1;31" for bold red let styles = ''; for (let part of parts) { @@ -47,6 +60,27 @@ function ansiToHtml(line) { }) + ''; } +/** + * Formats the name for the plot trace. + * @param {PlotConfig} config + * @param {Map} labels + * @return string + */ +function formatName(config, labels) { + var name = ""; + const formatter = config.name_format + if (formatter) { + name = eval(formatter); + } else { + var names = []; + for (const value of labels) { + names.push(value); + } + name = names.join(" "); + } + return name; +} + /** * Get's a css variable's value from the document. * @param {string} variableName - Name of the variable to get `--var-name` @@ -56,159 +90,77 @@ function getCssVariableValue(variableName) { return getComputedStyle(document.documentElement).getPropertyValue(variableName); } -/** - * Custom element for showing a plotly graph. - * - * @extends HTMLElement - */ -export class GraphPlot extends HTMLElement { - /** @type {?string} */ - #uri; +class ElementConfig { + uri; /** @type {?boolean} */ - #allowUriFilters; + allowUriFilters; /** @type {?number} */ - #width; + width; /** @type {?number} */ - #height; + height; /** @type {?number} */ - #intervalId; + intervalId; /** @type {?number} */ - #pollSeconds; + pollSeconds; /** @type {?string} */ - #end; + end; /** @type {?number} */ - #duration; + duration; /** @type {?string} */ - #step_duration; + step_duration; /** @type {?string} */ - #d3TickFormat = "~s"; + d3TickFormat = "~s"; /** @type {?HTMLDivElement} */ - #targetNode = null; + targetNode = null; /** @type {?HTMLElement} */ - #menuContainer = null; + menuContainer = null; /** @type {Object} */ - #filterSelectElements = {}; + filterSelectElements = {}; /** @type {Object>} */ - #filterLabels = {}; + filterLabels = {}; /** @type {Object>} */ - #filteredLabelSets = {}; + filteredLabelSets = {}; + /** @type {?HTMLElement} */ + #container = null; - constructor() { - super(); - this.#width = 800; - this.#height = 600; - this.#pollSeconds = 30; - this.#menuContainer = this.appendChild(document.createElement('div')); + constructor(/** @type {?HTMLElement} */ container) { + this.#container = container; + this.width = 800; + this.height = 600; + this.pollSeconds = 30; + this.menuContainer = this.#container.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")); + this.menuContainer.setAttribute("class", "row-flex"); + this.targetNode = this.#container.appendChild(document.createElement("div")); } - static observedAttributes = ['uri', 'width', 'height', 'poll-seconds', 'end', 'duration', 'step-duration', 'd3-tick-format', 'allow-uri-filter']; + connectedHandler(/** @type {HtmlElement} */ element) { + this.uri = element.getAttribute('uri') || this.uri; + this.width = Number(element.getAttribute('width') || this.width); + this.height = Number(element.getAttribute('height') || this.height); + this.pollSeconds = Number(element.getAttribute('poll-seconds') || this.pollSeconds); + this.end = element.getAttribute('end') || null; + this.duration = Number(element.getAttribute('duration')) || null; + this.step_duration = element.getAttribute('step-duration') || null; + this.d3TickFormat = element.getAttribute('d3-tick-format') || this.d3TickFormat; + this.allowUriFilters = Boolean(element.getAttribute('allow-uri-filters')); + } - /** - * 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 - */ - attributeChangedCallback(name, _oldValue, newValue) { - switch (name) { - case 'uri': - this.#uri = newValue; - break; - case 'width': - this.#width = Number(newValue); - break; - case 'height': - this.#height = Number(newValue); - break; - case 'poll-seconds': - this.#pollSeconds = Number(newValue); - break; - 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; - case 'allow-uri-filters': - this.#allowUriFilters = Boolean(newValue); - break; - default: // do nothing; - break; + stopInterval() { + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = null; } - 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); - 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; - this.#allowUriFilters = Boolean(this.getAttribute('allow-uri-filters')); - this.reset(true); - } - - disconnectedCallback() { - this.stopInterval() - } - - static elementName = "graph-plot"; - /* * Get's the target node for placing the plotly graph. * * @returns {?HTMLDivElement} */ getTargetNode() { - return this.#targetNode; - } - - /** - */ - stopInterval() { - if (this.#intervalId) { - clearInterval(this.#intervalId); - this.#intervalId = null; - } - } - - /** - * Resets the entire graph and then restarts polling. - * @param {boolean=} updateOnly - */ - reset(updateOnly) { - var self = this; - self.stopInterval() - self.fetchData().then((data) => { - if (!updateOnly) { - self.getLabelsForData(data.Metrics || data.Logs.lines); - 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); - } + return this.targetNode; } /** @@ -219,21 +171,21 @@ export class GraphPlot extends HTMLElement { getUri() { //var uriParts = [this.#uri]; var uriParts = []; - if (this.#end && this.#duration && this.#step_duration) { - uriParts.push("end=" + this.#end); - uriParts.push("duration=" + this.#duration); - uriParts.push("step_duration=" + this.#step_duration); + if (this.end && this.duration && this.step_duration) { + uriParts.push("end=" + this.end); + uriParts.push("duration=" + this.duration); + uriParts.push("step_duration=" + this.step_duration); } - if (this.#allowUriFilters) { - for (const filterName in this.#filteredLabelSets) { - const filterVals = this.#filteredLabelSets[filterName].join("|"); + if (this.allowUriFilters) { + for (const filterName in this.filteredLabelSets) { + const filterVals = this.filteredLabelSets[filterName].join("|"); uriParts.push(`filter-${filterName}=${filterVals}`) } } if (uriParts) { - return this.#uri + "?" + uriParts.join('&'); + return this.uri + "?" + uriParts.join('&'); } else { - return this.#uri; + return this.uri; } } @@ -250,29 +202,8 @@ export class GraphPlot extends HTMLElement { return data; } - /** - * Formats the name for the plot trace. - * @param {PlotConfig} config - * @param {Map} labels - * @return string - */ - formatName(config, labels) { - var name = ""; - const formatter = config.name_format - if (formatter) { - name = eval(formatter); - } else { - var names = []; - for (const value of labels) { - names.push(value); - } - name = names.join(" "); - } - return name; - } - getFilterLabels() { - return this.#filterLabels; + return this.filterLabels; } /** @@ -280,13 +211,13 @@ export class GraphPlot extends HTMLElement { */ populateFilterData(labels) { for (var key in labels) { - const label = this.#filterLabels[key]; + const label = this.filterLabels[key]; if (label) { if (!label.includes(labels[key])) { - this.#filterLabels[key].push(labels[key]); + this.filterLabels[key].push(labels[key]); } } else { - this.#filterLabels[key] = [labels[key]]; + this.filterLabels[key] = [labels[key]]; } } } @@ -295,7 +226,7 @@ export class GraphPlot extends HTMLElement { * @param {string} key * @returns {HTMLDivElement} */ - buildSelectElement(key) { + buildSelectElement(key, me) { var id = key + "-select" + Math.random(); const element = document.createElement("div"); const select = document.createElement("select"); @@ -307,7 +238,7 @@ export class GraphPlot extends HTMLElement { const optValue = "Select All: " + key; optElement.innerText = optValue; select.appendChild(optElement); - for (var opt of this.#filterLabels[key]) { + for (var opt of this.filterLabels[key]) { const optElement = document.createElement("option"); optElement.setAttribute("value", opt); optElement.setAttribute("selected", "selected"); @@ -344,36 +275,126 @@ export class GraphPlot extends HTMLElement { filteredValues.push(o.value); } } - self.#filteredLabelSets[key] = filteredValues; - self.reset(true); + self.filteredLabelSets[key] = filteredValues; + me.reset(true); }; element.appendChild(select); return element; } - buildFilterMenu() { + // FIXME(jwall): We pass the element down but that couples a little too tightly. We should do this differently. + buildFilterMenu(me) { // We need to maintain a stable order for these var children = []; - for (var key of Object.keys(this.#filterLabels).sort()) { + 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); + if (this.filterLabels[key].length > 1) { + const element = this.filterSelectElements[key] || this.buildSelectElement(key, me); children.push(element); } } - this.#menuContainer.replaceChildren(...children); + this.menuContainer.replaceChildren(...children); + } + + attributeChangedHandler(name, newValue) { + switch (name) { + case 'uri': + this.uri = newValue; + break; + case 'width': + this.width = Number(newValue); + break; + case 'height': + this.height = Number(newValue); + break; + case 'poll-seconds': + this.pollSeconds = Number(newValue); + break; + case 'end': + this.end = newValue; + break; + case 'duration': + this.config.duration = Number(newValue); + break; + case 'step-duration': + this.step_duration = newValue; + break; + case 'd3-tick-format': + this.config.d3TickFormat = newValue; + break; + case 'allow-uri-filters': + this.allowUriFilters = Boolean(newValue); + break; + default: // do nothing; + break; + } + } +} + + +/** + * Custom element for showing Log Output. + * + * @extends HTMLElement + */ +export class LogPlot extends HTMLElement { + /** @type {?ElementConfig} */ + #config; + + constructor() { + super(); + this.#config = new ElementConfig(this); + } + + static observedAttributes = ['uri', 'width', 'height', 'poll-seconds', 'end', 'duration', 'step-duration', 'd3-tick-format', 'allow-uri-filter']; + + /** + * 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 + */ + attributeChangedCallback(name, _oldValue, newValue) { + this.#config.attributeChangedHandler(name, newValue); + this.reset(); + } + + connectedCallback() { + this.#config.connectedHandler(this); + this.reset(true); + } + + disconnectedCallback() { + this.#config.stopInterval() + } + + static elementName = "log-plot"; + + /** Registers the custom element if it doesn't already exist */ + static registerElement() { + if (!customElements.get(LogPlot.elementName)) { + customElements.define(LogPlot.elementName, LogPlot); + } } /** - * @param {QueryData|LogLineList} graph - */ - getLabelsForData(graph) { - if (/** @type {QueryData} */(graph).plots) { - this.getLabelsForQueryData(/** @type {QueryData} */(graph)); - } else { - this.getLabelsForLogLines(/** @type {LogLineList} */(graph)); - } + * Resets the entire graph and then restarts polling. + * @param {boolean=} updateOnly + */ + reset(updateOnly) { + var self = this; + self.#config.stopInterval() + self.#config.fetchData().then((data) => { + if (!updateOnly) { + self.getLabelsForLogLines(data.Metrics || data.Logs.lines); + self.#config.buildFilterMenu(this); + } + self.updateGraph(data).then(() => { + self.#config.intervalId = setInterval(() => self.updateGraph(), 1000 * self.#config.pollSeconds); + }); + }); } /** @@ -383,7 +404,7 @@ export class GraphPlot extends HTMLElement { if (graph.Stream) { for (const pair of graph.Stream) { const labels = pair[0]; - this.populateFilterData(labels); + this.#config.populateFilterData(labels); } } if (graph.StreamInstant) { @@ -391,102 +412,6 @@ export class GraphPlot extends HTMLElement { } } - /** - * @param {QueryData} graph - */ - getLabelsForQueryData(graph) { - const data = graph.plots; - 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); - } - } - } - } - - yaxisNameGenerator() { - var counter = 1; - return function() { - var name = "yaxis"; - if (counter != 1) { - name = "yaxis" + counter; - } - counter++; - return name; - }; - } - - /** - * @param {any} triple - */ - buildSeriesPlot(triple) { - const labels = /** @type {Map} */(triple[0]); - for (var label in labels) { - var show = this.#filteredLabelSets[label]; - if (show && !show.includes(labels[label])) { - return null; - } - } - const config = /** @type {PlotConfig} */(triple[1]); - var yaxis = config.yaxis || "y"; - // https://plotly.com/javascript/reference/layout/yaxis/ - const series = triple[2]; - const trace = /** @type GraphTrace */({ - type: "scatter", - mode: "lines+text", - x: [], - y: [], - // We always share the x axis for timeseries graphs. - xaxis: "x", - yaxis: yaxis, - //yhoverformat: yaxis.tickformat, - }); - if (config.fill) { - trace.fill = config.fill; - } - var name = this.formatName(config, labels); - if (name) { trace.name = name; } - for (const point of series) { - trace.x.push(new Date(point.timestamp * 1000)); - trace.y.push(point.value); - } - return trace; - } - - /** - * @param {any} triple - */ - buildScalarPlot(triple) { - const labels = /** @type {Map} */(triple[0]); - for (var label in labels) { - var show = this.#filteredLabelSets[label]; - if (show && !show.includes(labels[label])) { - return null; - } - } - const config = /** @type {PlotConfig} */(triple[1]); - const series = triple[2]; - const trace = /** @type GraphTrace */({ - type: "bar", - x: [], - y: [], - yhoverformat: config["d3_tick_format"], - }); - var name = this.formatName(config, labels); - if (name) { trace.name = name; } - trace.y.push(series.value); - trace.x.push(trace.name); - return trace; - } - /** * @param {Array} stream * @@ -501,7 +426,7 @@ export class GraphPlot extends HTMLElement { const labels = pair[0]; var labelList = []; for (var label in labels) { - var show = this.#filteredLabelSets[label]; + var show = this.#config.filteredLabelSets[label]; if (show && !show.includes(labels[label])) { continue loopStream; } @@ -519,7 +444,7 @@ export class GraphPlot extends HTMLElement { } return { dates: dateColumn, config: configColumn, lines: logColumn }; } - + /** * Update the graph with new data. * @@ -528,10 +453,10 @@ export class GraphPlot extends HTMLElement { async updateGraph(maybeGraph) { var graph = maybeGraph; if (!graph) { - graph = await this.fetchData(); + graph = await this.#config.fetchData(); } if (graph.Metrics) { - this.updateMetricsGraph(graph.Metrics); + // FIXME(zaphar): Log an Error; } else if (graph.Logs) { this.updateLogsView(graph.Logs.lines); } else { @@ -588,7 +513,177 @@ export class GraphPlot extends HTMLElement { } // https://plotly.com/javascript/plotlyjs-function-reference/#plotlyreact // @ts-ignore - Plotly.react(this.getTargetNode(), traces, layout, null); + Plotly.react(this.#config.getTargetNode(), traces, layout, null); + } + +} + +LogPlot.registerElement(); + +/** + * Custom element for showing a plotly graph. + * + * @extends HTMLElement + */ +export class GraphPlot extends HTMLElement { + /** @type {?ElementConfig} */ + #config; + + constructor() { + super(); + this.#config = new ElementConfig(this); + } + + static observedAttributes = ['uri', 'width', 'height', 'poll-seconds', 'end', 'duration', 'step-duration', 'd3-tick-format', 'allow-uri-filter']; + + /** + * 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 + */ + attributeChangedCallback(name, _oldValue, newValue) { + this.#config.attributeChangedHandler(name, newValue); + this.reset(); + } + + connectedCallback() { + this.#config.connectedHandler(this); + this.reset(true); + } + + disconnectedCallback() { + this.#config.stopInterval() + } + + static elementName = "graph-plot"; + + /** + * Resets the entire graph and then restarts polling. + * @param {boolean=} updateOnly + */ + reset(updateOnly) { + var self = this; + self.#config.stopInterval() + self.#config.fetchData().then((data) => { + if (!updateOnly) { + self.getLabelsForQueryData(data.Metrics || data.Logs.lines); + self.#config.buildFilterMenu(this); + } + self.updateGraph(data).then(() => { + self.#config.intervalId = setInterval(() => self.updateGraph(), 1000 * self.#config.pollSeconds); + }); + }); + } + + /** Registers the custom element if it doesn't already exist */ + static registerElement() { + if (!customElements.get(GraphPlot.elementName)) { + customElements.define(GraphPlot.elementName, GraphPlot); + } + } + + /** + * @param {QueryData} graph + */ + getLabelsForQueryData(graph) { + const data = graph.plots; + for (var subplot of data) { + if (subplot.Series) { + for (const triple of subplot.Series) { + const labels = triple[0]; + this.#config.populateFilterData(labels); + } + } + if (subplot.Scalar) { + for (const triple of subplot.Scalar) { + const labels = triple[0]; + this.#config.populateFilterData(labels); + } + } + } + } + + /** + * @param {any} triple + */ + buildSeriesPlot(triple) { + const labels = /** @type {Map} */(triple[0]); + for (var label in labels) { + var show = this.#config.filteredLabelSets[label]; + if (show && !show.includes(labels[label])) { + return null; + } + } + const config = /** @type {PlotConfig} */(triple[1]); + var yaxis = config.yaxis || "y"; + // https://plotly.com/javascript/reference/layout/yaxis/ + const series = triple[2]; + const trace = /** @type GraphTrace */({ + type: "scatter", + mode: "lines+text", + x: [], + y: [], + // We always share the x axis for timeseries graphs. + xaxis: "x", + yaxis: yaxis, + //yhoverformat: yaxis.tickformat, + }); + if (config.fill) { + trace.fill = config.fill; + } + var name = formatName(config, labels); + if (name) { trace.name = name; } + for (const point of series) { + trace.x.push(new Date(point.timestamp * 1000)); + trace.y.push(point.value); + } + return trace; + } + + /** + * @param {any} triple + */ + buildScalarPlot(triple) { + const labels = /** @type {Map} */(triple[0]); + for (var label in labels) { + var show = this.#config.filteredLabelSets[label]; + if (show && !show.includes(labels[label])) { + return null; + } + } + const config = /** @type {PlotConfig} */(triple[1]); + const series = triple[2]; + const trace = /** @type GraphTrace */({ + type: "bar", + x: [], + y: [], + yhoverformat: config["d3_tick_format"], + }); + var name = formatName(config, labels); + if (name) { trace.name = name; } + trace.y.push(series.value); + trace.x.push(trace.name); + return trace; + } + + /** + * Update the graph with new data. + * + * @param {?QueryPayload=} maybeGraph + */ + async updateGraph(maybeGraph) { + var graph = maybeGraph; + if (!graph) { + graph = await this.#config.fetchData(); + } + if (graph.Metrics) { + this.updateMetricsGraph(graph.Metrics); + } else if (graph.Logs) { + // FIXME(zaphar): Log an Error; + } else { + } } /** @@ -617,16 +712,16 @@ export class GraphPlot extends HTMLElement { if (graph.legend_orientation) { layout.legend.orientation = graph.legend_orientation; } - var nextYaxis = this.yaxisNameGenerator(); + var nextYaxis = yaxisNameGenerator(); for (const yaxis of yaxes) { - yaxis.tickformat = yaxis.tickformat || this.#d3TickFormat; + yaxis.tickformat = yaxis.tickformat || this.#config.d3TickFormat; yaxis.gridColor = getCssVariableValue("--grid-line-color"); layout[nextYaxis()] = yaxis; } var traces = /** @type {Array} */ ([]); for (var subplot_idx in data) { const subplot = data[subplot_idx]; - var nextYaxis = this.yaxisNameGenerator(); + var nextYaxis = yaxisNameGenerator(); if (subplot.Series) { // https://plotly.com/javascript/reference/scatter/ for (const triple of subplot.Series) { @@ -647,7 +742,7 @@ export class GraphPlot extends HTMLElement { } // https://plotly.com/javascript/plotlyjs-function-reference/#plotlyreact // @ts-ignore - Plotly.react(this.getTargetNode(), traces, layout, null); + Plotly.react(this.#config.getTargetNode(), traces, layout, null); } } @@ -702,6 +797,11 @@ export class SpanSelector extends HTMLElement { node.setAttribute('duration', this.#durationInput.value); node.setAttribute('step-duration', this.#stepDurationInput.value); } + for (var node of document.getElementsByTagName(LogPlot.elementName)) { + node.setAttribute('end', this.#endInput.value); + node.setAttribute('duration', this.#durationInput.value); + node.setAttribute('step-duration', this.#stepDurationInput.value); + } } static elementName = "span-selector";