diff --git a/.dashboard.js b/.dashboard.js index 636619f..f94b02c 100644 --- a/.dashboard.js +++ b/.dashboard.js @@ -1,55 +1,95 @@ -// helper for adding p(99) to existing chart -function addP99 (chart) { - chart.series = { - ...chart.series, - 'http_req_duration_trend_p(99)': { label: 'p(99)', format: 'duration' } +//@ts-check +/** + * @typedef {import('./config').dashboard.Config} Config + * @typedef {import('./config').dashboard.Tab} Tab + * @typedef {import('./config').dashboard.Panel} Panel + * @typedef {import('./config').dashboard.Chart} Chart + */ + +/** + * Customize dashboard configuration. + * @param {Config} config default dashboard configuration + * @returns {Config} modified dashboard configuration + */ +export default function (config) { + /** + * Search for an array element that has a given id property value. + * @param {string} id the id for the search + * @returns the first element whose id property matches or is undefined if there are no results + */ + function getById(id) { + return this.filter((/** @type {{ id: string; }} */ element) => element.id == id).at(0) } -} -// define request duration panel -function durationPanel (suffix) { - return { - id: `http_req_duration_${suffix}`, - title: `HTTP Request Duration ${suffix}`, - metric: `http_req_duration_trend_${suffix}`, - format: 'duration' + // add getById method to all array + Array.prototype["getById"] = getById + + /** + * helper for adding p(99) to existing chart + * @param {Chart} chart + */ + function addP99 (chart) { + chart.series = Object.assign({}, chart.series) + chart.series['http_req_duration.p(99)'] = { label: 'p(99)', format: 'duration' } } -} -// copy vus and http_reqs panel from default config -const overview = defaultConfig.tab('overview_snapshot') - -// define custom panels -const customPanels = [ - overview.panel('vus'), - overview.panel('http_reqs'), - durationPanel('avg'), - durationPanel('p(90)'), - durationPanel('p(95)'), - durationPanel('p(99)') -] - -// copy http_req_duration chart form default config... -const durationChart = { ...overview.chart('http_req_duration') } - -// ... and add p(99) -addP99(durationChart) - -// uncomment to add cumulative tabs -// defaultConfig.tabs.push(defaultConfig.tabOverview('cumulative')) -// defaultConfig.tabs.push(defaultConfig.tabTimings('cumulative')) - -// define custom tab -const customTab = { - id: 'custom', - title: 'Custom', - event: overview.event, - panels: customPanels, - charts: [overview.chart('http_reqs'), durationChart], - description: 'Example of customizing the display of metrics.' -} + /** + * define request duration panel + * @param {string} suffix + * @returns {Panel} panel + */ + function durationPanel (suffix) { + return { + id: `http_req_duration_${suffix}`, + title: `HTTP Request Duration ${suffix}`, + metric: `http_req_duration_trend_${suffix}`, + format: 'duration' + } + } + + /** + * reference to overview tab from default config + * @type {Tab} + */ + const overview = config.tabs.getById('overview_snapshot') -// add custom tab to configuration -defaultConfig.tabs.push(customTab) + /** + * define custom panels + * @type {Panel[]} + */ + const customPanels = [ + overview.panels.getById('vus'), + overview.panels.getById('http_reqs'), + durationPanel('avg'), + durationPanel('p(90)'), + durationPanel('p(95)'), + durationPanel('p(99)') + ] -export default defaultConfig + /** + * copy of the http_req_duration chart form default config + * @type {Chart} + */ + const durationChart = Object.assign({}, overview.charts.getById('http_req_duration')) + + // and add p(99) + addP99(durationChart) + + /** + * custom tab definition + * @type {Tab} + */ + const customTab = { + id: 'custom', + title: 'Custom', + event: overview.event, + panels: customPanels, + charts: [overview.charts.getById('http_reqs'), durationChart], + description: 'Example of customizing the display of metrics.' + } + + // add custom tab to configuration + config.tabs.push(customTab) + + return config +} diff --git a/.golangci.yml b/.golangci.yml index a4a9cd6..af47db9 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -50,8 +50,10 @@ linters-settings: - github.com/gorilla/schema - github.com/tidwall/gjson - github.com/r3labs/sse/v2 + - github.com/dop251/goja - github.com/grafana/xk6-dashboard/assets - github.com/grafana/xk6-dashboard/dashboard + - github.com/grafana/xk6-dashboard/customize test: files: - $test @@ -59,5 +61,7 @@ linters-settings: - $gostd - github.com/stretchr/testify/assert - github.com/sirupsen/logrus + - github.com/dop251/goja + - github.com/tidwall/gjson - github.com/grafana/xk6-dashboard/assets - github.com/grafana/xk6-dashboard/dashboard diff --git a/README.md b/README.md index 363e498..16ff1c9 100644 --- a/README.md +++ b/README.md @@ -139,8 +139,8 @@ host | Hostname or IP address for HTTP endpoint (default: "", empty, listen port | TCP port for HTTP endpoint (default: `5665`; `0` = random, `-1` = no HTTP), example: `8080` period | Event emitting frequency (default: `10s`), example: `1m` open | Set to `true` (or empty) to open the browser window automatically -config | UI configuration file location (default: `.dashboard.js`) (see [Customization](#customization)) -report | File name to save the report (dafault: "", empty, the report will not be saved) +report | File name to save the report (default: "", empty, the report will not be saved) +tag | Precomputed metric tag name(s) (default: "group"), can be specified more than once ## Docker @@ -190,26 +190,24 @@ The `/events` endpoint (default: http://127.0.0.1:5665/events) is a standard SSE Events will be emitted periodically based on the `period` parameter (default: `10s`). The event's `data` is a JSON object with metric names as property names and metric values as property values. The format is similar to the [List Metrics](https://k6.io/docs/misc/k6-rest-api/#list-metrics) response format from the [k6 REST API](https://k6.io/docs/misc/k6-rest-api/). Two kind of events will be emitted: + - `config` contains ui configuration + - `param` contains main extension parameters (period, scenarios, thresholds, etc) + - `start` contains start timestamp + - `stop` contains stop timestamp + - `metric` contains new metric definitions - `snapshot` contains metric values from last period - `cumulative` contains cumulative metric values from the test starting point -**Example events** - -```plain -event: snapshot -id: 1 -data: {"checks":{"type":"rate","contains":"default","tainted":null,"sample":{"rate":0}},"data_received":{"type":"counter","contains":"data","tainted":null,"sample":{"count":11839,"rate":5919.5}},"data_sent":{"type":"counter","contains":"data","tainted":null,"sample":{"count":202,"rate":101}},"http_req_blocked":{"type":"trend","contains":"time","tainted":null,"sample":{"avg":0.0037155,"max":0.00485,"med":0.0037155,"min":0.002581,"p(90)":0.0046231,"p(95)":0.00473655}},"http_req_connecting":{"type":"trend","contains":"time","tainted":null,"sample":{"avg":0,"max":0,"med":0,"min":0,"p(90)":0,"p(95)":0}},"http_req_duration":{"type":"trend","contains":"time","tainted":null,"sample":{"avg":120.917558,"max":120.928988,"med":120.917558,"min":120.906128,"p(90)":120.926702,"p(95)":120.927845}},"http_req_failed":{"type":"rate","contains":"default","tainted":null,"sample":{"rate":0}},"http_req_receiving":{"type":"trend","contains":"time","tainted":null,"sample":{"avg":0.0709745,"max":0.088966,"med":0.0709745,"min":0.052983,"p(90)":0.0853677,"p(95)":0.08716685}},"http_req_sending":{"type":"trend","contains":"time","tainted":null,"sample":{"avg":0.022489500000000003,"max":0.033272,"med":0.022489500000000003,"min":0.011707,"p(90)":0.031115500000000004,"p(95)":0.03219375}},"http_req_tls_handshaking":{"type":"trend","contains":"time","tainted":null,"sample":{"avg":0,"max":0,"med":0,"min":0,"p(90)":0,"p(95)":0}},"http_req_waiting":{"type":"trend","contains":"time","tainted":null,"sample":{"avg":120.824094,"max":120.841438,"med":120.824094,"min":120.80675,"p(90)":120.8379692,"p(95)":120.83970359999999}},"http_reqs":{"type":"counter","contains":"default","tainted":null,"sample":{"count":2,"rate":1}},"iteration_duration":{"type":"trend","contains":"time","tainted":null,"sample":{"avg":3244.614784,"max":3244.614784,"med":3244.614784,"min":3244.614784,"p(90)":3244.614784,"p(95)":3244.614784}},"iterations":{"type":"counter","contains":"default","tainted":null,"sample":{"count":1,"rate":0.5}},"time":{"type":"gauge","contains":"time","tainted":null,"sample":{"value":1679907081015}},"vus":{"type":"gauge","contains":"default","tainted":null,"sample":{"value":1}},"vus_max":{"type":"gauge","contains":"default","tainted":null,"sample":{"value":2}}} - -event: cumulative -id: 1 -data: {"checks":{"type":"rate","contains":"default","tainted":null,"sample":{"rate":0}},"data_received":{"type":"counter","contains":"data","tainted":null,"sample":{"count":46837,"rate":1115.1362807429666}},"data_sent":{"type":"counter","contains":"data","tainted":null,"sample":{"count":1653,"rate":39.35607045857172}},"http_req_blocked":{"type":"trend","contains":"time","tainted":null,"sample":{"avg":88.12648020000002,"max":456.345376,"med":0.0056419999999999994,"min":0.00219,"p(90)":262.8713841999999,"p(95)":359.60838009999975}},"http_req_connecting":{"type":"trend","contains":"time","tainted":null,"sample":{"avg":37.2988213,"max":131.097342,"med":0,"min":0,"p(90)":122.40998579999999,"p(95)":126.75366389999999}},"http_req_duration":{"type":"trend","contains":"time","tainted":null,"sample":{"avg":123.92543040000001,"max":133.508481,"med":121.77833150000001,"min":120.412089,"p(90)":132.29845799999998,"p(95)":132.9034695}},"http_req_failed":{"type":"rate","contains":"default","tainted":null,"sample":{"rate":0.2}},"http_req_receiving":{"type":"trend","contains":"time","tainted":null,"sample":{"avg":0.10157959999999999,"max":0.337678,"med":0.0826445,"min":0.052983,"p(90)":0.11383719999999992,"p(95)":0.22575759999999973}},"http_req_sending":{"type":"trend","contains":"time","tainted":null,"sample":{"avg":0.035149900000000005,"max":0.096238,"med":0.0272325,"min":0.011707,"p(90)":0.06422679999999999,"p(95)":0.08023239999999997}},"http_req_tls_handshaking":{"type":"trend","contains":"time","tainted":null,"sample":{"avg":38.9789687,"max":268.92473,"med":0,"min":0,"p(90)":135.67093429999994,"p(95)":202.29783214999986}},"http_req_waiting":{"type":"trend","contains":"time","tainted":null,"sample":{"avg":123.78870090000001,"max":133.411013,"med":121.5094465,"min":120.326814,"p(90)":132.15912649999999,"p(95)":132.78506975}},"http_reqs":{"type":"counter","contains":"default","tainted":null,"sample":{"count":10,"rate":0.23808875050557607}},"iteration_duration":{"type":"trend","contains":"time","tainted":null,"sample":{"avg":3626.924762,"max":4258.763721,"med":3377.395781,"min":3244.614784,"p(90)":4082.4901330000002,"p(95)":4170.626927}},"iterations":{"type":"counter","contains":"default","tainted":null,"sample":{"count":3,"rate":0.07142662515167282}},"time":{"type":"gauge","contains":"time","tainted":null,"sample":{"value":1679907081015}},"vus":{"type":"gauge","contains":"default","tainted":null,"sample":{"value":1}},"vus_max":{"type":"gauge","contains":"default","tainted":null,"sample":{"value":2}}} -``` - ## Customization -The embedded user interface can be customized using a single JavaScript configuration file specified in the `config` parameter (default: `.dashboard.js` in the current directory). The configuration file is an ES6 module that is executed in the browser. The module's default export is a JavaScript configuration object. +The embedded user interface can be customized using a single JavaScript configuration file specified in the `XK6_DASHBOARD_CONFIG` environment variable (default: `.dashboard.js` in the current directory). The configuration file is an ES6 module. The module's default export is a JavaScript function which returns a configuration object. The default configuration is passed as argument to the exported function. + +The default configuration is loaded from the [assets/packages/config/dist/config.json](assets/packages/config/dist/config.json) file, which can give you ideas for creating your own configuration. -Before executing the configuration file, the `window.defaultConfig` object is created with the default configuration. The default configuration is loaded from the [ui/assets/ui/public/boot.js](ui/assets/ui/public/boot.js) file, which can give you ideas for creating your own configuration. +> **Warning** +> The format of the custom configuration has changed! +> The stability of the configuration format is still not guaranteed, so you should check the changes before updating the version. +> In addition, it is possible that the custom configuration will be limited or phased out in the future. ### Examples @@ -219,57 +217,61 @@ Before executing the configuration file, the `window.defaultConfig` object is cr In this example, a tab called *Custom* is defined, which contains six panels and two charts. The first two panels are just a reference to the two panels of the built-in *Overview* tab. ```js -// helper for adding p(99) to existing chart -function addP99 (chart) { - chart.series = { - ...chart.series, - 'http_req_duration_trend_p(99)': { label: 'p(99)' } +export default function (config) { + Array.prototype.getById = function (id) { + return this.filter(element => element.id == id).at(0) } -} -// define request duration panel -function durationPanel (suffix) { - return { - id: `http_req_duration_${suffix}`, - title: `Request Duration ${suffix}`, - metric: `http_req_duration_trend_${suffix}`, - format: 'duration' + // helper for adding p(99) to existing chart + function addP99 (chart) { + chart.series = Object.assign({}, chart.series) + chart.series['http_req_duration.p(99)'] = { label: 'p(99)', format: 'duration' } } -} -// copy vus and http_reqs panel from default config -const overview = defaultConfig.tab('overview_snapshot') - -// define custom panels -const customPanels = [ - overview.panel('vus'), - overview.panel('http_reqs'), - durationPanel('avg'), - durationPanel('p(90)'), - durationPanel('p(95)'), - durationPanel('p(99)') -] - -// copy http_req_duration chart form default config... -const durationChart = { ...overview.chart('http_req_duration') } - -// ... and add p(99) -addP99(durationChart) - -// define custom tab -const customTab = { - id: 'custom', - title: 'Custom', - event: overview.event, - panels: customPanels, - charts: [overview.chart('http_reqs'), durationChart], - description: 'Example of customizing the display of metrics.' -} + // define request duration panel + function durationPanel (suffix) { + return { + id: `http_req_duration_${suffix}`, + title: `HTTP Request Duration ${suffix}`, + metric: `http_req_duration.${suffix}`, + format: 'duration' + } + } + + // copy vus and http_reqs panel from default config + const overview = config.tabs.getById('overview_snapshot') + + // define custom panels + const customPanels = [ + overview.panels.getById('vus'), + overview.panels.getById('http_reqs'), + durationPanel('avg'), + durationPanel('p(90)'), + durationPanel('p(95)'), + durationPanel('p(99)') + ] + + // copy http_req_duration chart form default config... + const durationChart = Object.assign({}, overview.charts.getById('http_req_duration')) + + // ... and add p(99) + addP99(durationChart) + + // define custom tab + const customTab = { + id: 'custom', + title: 'Custom', + event: overview.event, + panels: customPanels, + charts: [overview.charts.getById('http_reqs'), durationChart], + description: 'Example of customizing the display of metrics.' + } -// add custom tab to configuration -defaultConfig.tabs.push(customTab) + // add custom tab to configuration + config.tabs.push(customTab) -export default defaultConfig + return config +} ``` **p(99)** @@ -277,16 +279,21 @@ export default defaultConfig In this example, the 99th percentile value is added to the *Request Duration* chart on the built-in *Overview* tabs. ```js -// helper for adding p(99) to existing chart -function addP99 (chart) { - chart.series['http_req_duration_trend_p(99)'] = { label: 'p(99)' } -} +export default function (config) { + Array.prototype.getById = function (id) { + return this.filter((element) => element.id == id).at(0); + }; + + // helper for adding p(99) to existing chart + function addP99(chart) { + chart.series["http_req_duration.p(99)"] = { label: "p(99)" }; + } -// add p(99) to overview panels request duration charts -addP99(defaultConfig.tab('overview_snapshot').chart('http_req_duration')) -addP99(defaultConfig.tab('overview_cumulative').chart('http_req_duration')) + // add p(99) to overview panels request duration charts + addP99(config.tabs.getById("overview_snapshot").charts.getById("http_req_duration")); -export default defaultConfig + return config +} ``` ## Command Line @@ -322,12 +329,12 @@ Usage: k6 dashboard replay file [flags] Flags: - --config string UI configuration file location (default ".dashboard.js") --host string Hostname or IP address for HTTP endpoint (default: '', empty, listen on all interfaces) --open Open browser window automatically --period 1m Event emitting frequency, example: 1m (default 10s) --port int TCP port for HTTP endpoint (0=random, -1=no HTTP), example: 8080 (default 5665) --report string Report file location (default: '', no report) + --tags strings Precomputed metric tags (default [group]) can be specified more than once -h, --help help for replay ``` diff --git a/assets.go b/assets.go new file mode 100644 index 0000000..be2486d --- /dev/null +++ b/assets.go @@ -0,0 +1,41 @@ +// SPDX-FileCopyrightText: 2023 Iván Szkiba +// +// SPDX-License-Identifier: MIT + +package dashboard + +import ( + "embed" + "io/fs" +) + +//go:embed assets/packages/ui/dist assets/packages/brief/dist assets/packages/config/dist +var distFS embed.FS + +const base = "assets/packages/" + +func dirUI() fs.FS { + return dir(base + "ui/dist") +} + +func dirBrief() fs.FS { + return dir(base + "brief/dist") +} + +func fileConfig() []byte { + config, err := distFS.ReadFile(base + "config/dist/config.json") + if err != nil { + panic(err) + } + + return config +} + +func dir(dirname string) fs.FS { + subfs, err := fs.Sub(distFS, dirname) + if err != nil { + panic(err) + } + + return subfs +} diff --git a/assets/assets.go b/assets/assets.go deleted file mode 100644 index d554089..0000000 --- a/assets/assets.go +++ /dev/null @@ -1,30 +0,0 @@ -// SPDX-FileCopyrightText: 2023 Iván Szkiba -// -// SPDX-License-Identifier: MIT - -package assets - -import ( - "embed" - "io/fs" -) - -//go:embed ui brief -var distFS embed.FS - -func DirUI() fs.FS { - return dir("ui") -} - -func DirBrief() fs.FS { - return dir("brief") -} - -func dir(dirname string) fs.FS { - subfs, err := fs.Sub(distFS, dirname) - if err != nil { - panic(err) - } - - return subfs -} diff --git a/assets/brief/boot.js b/assets/brief/boot.js deleted file mode 100644 index 37658bb..0000000 --- a/assets/brief/boot.js +++ /dev/null @@ -1,188 +0,0 @@ -const overviewPanels = [ - { - id: 'iterations', - title: 'Iteration Rate', - metric: 'iterations_counter_rate', - format: 'rps' - }, - { - id: 'vus', - title: 'VUs', - metric: 'vus_gauge_value', - format: 'counter' - }, - { - id: 'http_reqs', - title: 'HTTP Request Rate', - metric: 'http_reqs_counter_rate', - format: 'rps' - }, - { - id: 'http_req_duration', - title: 'HTTP Request Duration', - metric: 'http_req_duration_trend_avg', - format: 'duration' - }, - { - id: 'data_received', - title: 'Received Rate', - metric: 'data_received_counter_rate', - format: 'bps' - }, - { - id: 'data_sent', - title: 'Sent Rate', - metric: 'data_sent_counter_rate', - format: 'bps' - } -] - -const overviewCharts = [ - { - id: 'http_reqs', - title: 'VUs', - series: { - vus_gauge_value: { label: 'VUs', width: 2, scale: 'n', format: 'counter' }, - http_reqs_counter_rate: { label: 'HTTP request rate', scale: '1/s', format: 'rps' } - }, - axes: [{}, { scale: 'n' }, { scale: '1/s', side: 1, format: 'rps' }], - scales: [{}, {}, {}] - }, - { - id: 'data', - title: 'Transfer Rate', - series: { - data_sent_counter_rate: { label: 'data sent', rate: true, scale: 'sent', format: 'bps' }, - data_received_counter_rate: { - label: 'data received', - rate: true, - with: 2, - scale: 'received', - format: 'bps' - } - }, - axes: [{}, { scale: 'sent', format: 'bps' }, { scale: 'received', side: 1, format: 'bps' }] - }, - { - id: 'http_req_duration', - title: 'HTTP Request Duration', - series: { - http_req_duration_trend_avg: { label: 'avg', width: 2, format: 'duration' }, - 'http_req_duration_trend_p(90)': { label: 'p(90)', format: 'duration' }, - 'http_req_duration_trend_p(95)': { label: 'p(95)', format: 'duration' } - }, - axes: [{}, {format: 'duration'}, { side: 1, format: 'duration' }] - }, - { - id: 'iteration_duration', - title: 'Iteration Duration', - series: { - iteration_duration_trend_avg: { label: 'avg', width: 2, format: 'duration' }, - 'iteration_duration_trend_p(90)': { label: 'p(90)', format: 'duration' }, - 'iteration_duration_trend_p(95)': { label: 'p(95)', format: 'duration' } - }, - axes: [{}, {format: 'duration'}, { side: 1, format: 'duration' }] - } -] - -function suffix (event) { - return event == 'snapshot' ? '' : ' (cum)' -} - -function reportable (event) { - return event == 'snapshot' -} - -function tabOverview (event) { - return { - id: `overview_${event}`, - title: `Overview${suffix(event)}`, - event: event, - panels: overviewPanels, - charts: overviewCharts, - description: - 'This section provides an overview of the most important metrics of the test run. Graphs plot the value of metrics over time.' - } -} - -function chartTimings (metric, title) { - return { - id: metric, - title: title, - series: { - [`${metric}_trend_avg`]: { label: 'avg', width: 2, format: 'duration' }, - [`${metric}_trend_p(90)`]: { label: 'p(90)', format: 'duration' }, - [`${metric}_trend_p(95)`]: { label: 'p(95)', format: 'duration' } - }, - axes: [{}, {format: 'duration'}, { side: 1, format: 'duration' }], - height: 224 - } -} - -function tabTimings (event) { - return { - id: `timings_${event}`, - title: `Timings${suffix(event)}`, - event: event, - charts: [ - chartTimings('http_req_duration', 'HTTP Request Duration'), - chartTimings('http_req_waiting', 'HTTP Request Waiting'), - chartTimings('http_req_tls_handshaking', 'HTTP TLS handshaking'), - chartTimings('http_req_sending', 'HTTP Request Sending'), - chartTimings('http_req_connecting', 'HTTP Request Connecting'), - chartTimings('http_req_receiving', 'HTTP Request Receiving') - ], - report: reportable(event), - description: - 'This section provides an overview of test run HTTP timing metrics. Graphs plot the value of metrics over time.' - } -} - -const defaultConfig = { - title: 'k6 dashboard', - tabs: [ - tabOverview('snapshot'), - tabTimings('snapshot'), - ], - - tabOverview, - tabTimings, - - tab (id) { - let tab = null - - for (const t of this.tabs) { - if (t.id == id) { - tab = t - - break - } - } - - if (tab == null) { - tab = { id: id } - - this.tabs.push(tab) - } - - let lookup = (collection, id) => { - for (const item of collection) { - if (item.id == id) { - return item - } - } - - let item = { id: id } - collection.push(item) - - return item - } - - tab.chart = id => lookup(tab.charts, id) - tab.panel = id => lookup(tab.panels, id) - - return tab - } -} - -window.defaultConfig = defaultConfig diff --git a/assets/brief/index.html b/assets/brief/index.html deleted file mode 100644 index c125f51..0000000 --- a/assets/brief/index.html +++ /dev/null @@ -1,49 +0,0 @@ - - - -
- - - -{props.description}
- Select a time interval by holding down the mouse on any graph to zoom. To cancel zoom, double click on any graph. + Select a time interval by holding down the mouse on any graph to zoom. To cancel zoom, double click on any graph.