mirror of https://github.com/prometheus/prometheus
Integrate beginning of React UI (#5694)
* Initial commit from Create React App Signed-off-by: Julius Volz <julius.volz@gmail.com> * Initial Prometheus expression browser code Signed-off-by: Julius Volz <julius.volz@gmail.com> * Grpahing, try out echarts Signed-off-by: Julius Volz <julius.volz@gmail.com> * Switch to flot Signed-off-by: Julius Volz <julius.volz@gmail.com> * Add metrics fetching and stuff Signed-off-by: Julius Volz <julius.volz@gmail.com> * Autosuggest and graph improvements Signed-off-by: Julius Volz <julius.volz@gmail.com> * Start implementing graph controls, add loading spinner Signed-off-by: Julius Volz <julius.volz@gmail.com> * So many new features and fixes Signed-off-by: Julius Volz <julius.volz@gmail.com> * Fixed and built more features Signed-off-by: Julius Volz <julius.volz@gmail.com> * Make datetimepicker clear work Signed-off-by: Julius Volz <julius.volz@gmail.com> * Don't abort when executing empty expression Signed-off-by: Julius Volz <julius.volz@gmail.com> * Remove TabPaneAlert Signed-off-by: Julius Volz <julius.volz@gmail.com> * Split components into separate files Signed-off-by: Julius Volz <julius.volz@gmail.com> * Add table time input Signed-off-by: Julius Volz <julius.volz@gmail.com> * Move first files to TypeScript! Signed-off-by: Julius Volz <julius.volz@gmail.com> * More TypeScript conversions Signed-off-by: Julius Volz <julius.volz@gmail.com> * More TS conversions Signed-off-by: Julius Volz <julius.volz@gmail.com> * More TS conversions Signed-off-by: Julius Volz <julius.volz@gmail.com> * More TS conversions Signed-off-by: Julius Volz <julius.volz@gmail.com> * More TS conversions Signed-off-by: Julius Volz <julius.volz@gmail.com> * More TS fixes Signed-off-by: Julius Volz <julius.volz@gmail.com> * Convert Graph to TS Signed-off-by: Julius Volz <julius.volz@gmail.com> * Changes Signed-off-by: Julius Volz <julius.volz@gmail.com> * Resize detector, start building legend, axis font colors Signed-off-by: Julius Volz <julius.volz@gmail.com> * Make graph legend work Signed-off-by: Julius Volz <julius.volz@gmail.com> * Add URL params support and much more Signed-off-by: Julius Volz <julius.volz@gmail.com> * Put panel state into panel list, write URL options Signed-off-by: Julius Volz <julius.volz@gmail.com> * Change order of Graph and Table tabs Signed-off-by: Julius Volz <julius.volz@gmail.com> * Generalize time input naming more Signed-off-by: Julius Volz <julius.volz@gmail.com> * Work on history functionality Signed-off-by: Julius Volz <julius.volz@gmail.com> * npm updates Signed-off-by: Julius Volz <julius.volz@gmail.com> * Move loading indicator into "Execute" button Signed-off-by: Julius Volz <julius.volz@gmail.com> * Fix typo Signed-off-by: Julius Volz <julius.volz@gmail.com> * Revert "Move loading indicator into "Execute" button" This reverts commit ce7daee1f1af35da6c0d8b5517272839285ccfec. Signed-off-by: Julius Volz <julius.volz@gmail.com> * Improve error message when failing to fetch server time Signed-off-by: Julius Volz <julius.volz@gmail.com> * Move all code to Prometheus repo target dir Signed-off-by: Julius Volz <julius.volz@gmail.com> * Add react-app Makefile step and check in generated assets Signed-off-by: Julius Volz <julius.volz@gmail.com> * Add preliminary npm packages notice to NOTICE file Signed-off-by: Julius Volz <julius.volz@gmail.com> * Update React app's favicon and metadata Signed-off-by: Julius Volz <julius.volz@gmail.com> * Remove RP server refs, cleanups Signed-off-by: Julius Volz <julius.volz@gmail.com> * Use CircleCI image that includes NodeJS Signed-off-by: Julius Volz <julius.volz@gmail.com> * Add some missing React output assets Signed-off-by: Julius Volz <julius.volz@gmail.com> * Preserve CRLF in generated React files Signed-off-by: Julius Volz <julius.volz@gmail.com> * Switch from npm to yarn for React UI Signed-off-by: Julius Volz <julius.volz@gmail.com> * Save npm licenses and include them in release tarball Signed-off-by: Julius Volz <julius.volz@gmail.com> * Install npm on Travis Signed-off-by: Julius Volz <julius.volz@gmail.com> * Remove npm license tarball from source Signed-off-by: Julius Volz <julius.volz@gmail.com> * Remove React graph bundle from source Signed-off-by: Julius Volz <julius.volz@gmail.com> * Don't check in any compiled web assets Signed-off-by: Julius Volz <julius.volz@gmail.com> * Update README.md with node/yarn/React UI info Signed-off-by: Julius Volz <julius.volz@gmail.com> * Fix asset build step on CircleCI promu crossbuild Signed-off-by: Julius Volz <julius.volz@gmail.com> * Try to fix multi-arch go generate Signed-off-by: Julius Volz <julius.volz@gmail.com> * Remove check_assets from Travis CI build Signed-off-by: Julius Volz <julius.volz@gmail.com> * Prevent rebuilding of unchanged React app parts Signed-off-by: Julius Volz <julius.volz@gmail.com> * Fix npm license tarball path for promu Signed-off-by: Julius Volz <julius.volz@gmail.com> * Simplify Makefile Signed-off-by: Julius Volz <julius.volz@gmail.com> * Clarify build instructions in README.md Signed-off-by: Julius Volz <julius.volz@gmail.com> * Make minimal JS test pass Signed-off-by: Julius Volz <julius.volz@gmail.com> * Integrate React app tests into Makefile Signed-off-by: Julius Volz <julius.volz@gmail.com> * Separate react-app-tests target, but run it from CI Signed-off-by: Julius Volz <julius.volz@gmail.com> * Fix working directory for React app tests Signed-off-by: Julius Volz <julius.volz@gmail.com> * Remove local modifications to Makefile.common This means that CircleCI will not run the React app tests, but at least Travis still will... Signed-off-by: Julius Volz <julius.volz@gmail.com> * Depend on node_modules path for npm_licenses target Signed-off-by: Julius Volz <julius.volz@gmail.com> * Simplify tarball/docker/build Makefile targets Signed-off-by: Julius Volz <julius.volz@gmail.com> * Include React tests in "test" target Signed-off-by: Julius Volz <julius.volz@gmail.com> * Remove reference to removed "check_assets" target Signed-off-by: Julius Volz <julius.volz@gmail.com> * Do initial resize of expression input field Signed-off-by: Julius Volz <julius.volz@gmail.com> * Add React app proxying to local Prometheus in dev mode Signed-off-by: Julius Volz <julius.volz@gmail.com>pull/6159/head
parent
16370e6880
commit
bca6e90ea6
@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Build React web UI.
|
||||
# Run from repository root.
|
||||
set -e
|
||||
set -u
|
||||
|
||||
if ! [[ "$0" =~ "scripts/build_react_app.sh" ]]; then
|
||||
echo "must be run from repository root"
|
||||
exit 255
|
||||
fi
|
||||
|
||||
cd web/ui/react-app
|
||||
|
||||
echo "building React app"
|
||||
PUBLIC_URL=. yarn build
|
||||
rm -rf ../static/graph-new
|
||||
mv build ../static/graph-new
|
||||
# Prevent bad redirect due to Go HTTP router treating index.html specially.
|
||||
mv ../static/graph-new/index.html ../static/graph-new/app.html
|
File diff suppressed because one or more lines are too long
@ -0,0 +1,23 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
@ -0,0 +1,56 @@
|
||||
{
|
||||
"name": "graph",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^1.2.14",
|
||||
"@fortawesome/free-solid-svg-icons": "^5.7.1",
|
||||
"@fortawesome/react-fontawesome": "^0.1.4",
|
||||
"@types/jest": "^24.0.4",
|
||||
"@types/jquery": "^3.3.29",
|
||||
"@types/node": "^11.9.3",
|
||||
"@types/react": "^16.8.2",
|
||||
"@types/react-dom": "^16.8.0",
|
||||
"@types/react-resize-detector": "^3.1.0",
|
||||
"bootstrap": "^4.2.1",
|
||||
"downshift": "^3.2.2",
|
||||
"flot": "^2.1.6",
|
||||
"fuzzy": "^0.1.3",
|
||||
"i": "^0.3.6",
|
||||
"jquery": "^3.3.1",
|
||||
"jquery.flot.tooltip": "^0.9.0",
|
||||
"jsdom": "^9.6.0",
|
||||
"moment": "^2.24.0",
|
||||
"moment-timezone": "^0.5.23",
|
||||
"popper.js": "^1.14.3",
|
||||
"react": "^16.7.0",
|
||||
"react-dom": "^16.7.0",
|
||||
"react-resize-detector": "^3.4.0",
|
||||
"react-scripts": "^2.1.5",
|
||||
"reactstrap": "^7.1.0",
|
||||
"tempusdominus-bootstrap-4": "^5.1.2",
|
||||
"tempusdominus-core": "^5.0.3",
|
||||
"typescript": "^3.3.3"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": "react-app"
|
||||
},
|
||||
"browserslist": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not ie <= 11",
|
||||
"not op_mini all"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@types/flot": "0.0.31",
|
||||
"@types/moment-timezone": "^0.5.10",
|
||||
"@types/reactstrap": "^7.1.3"
|
||||
},
|
||||
"proxy": "http://localhost:9090"
|
||||
}
|
After Width: | Height: | Size: 15 KiB |
@ -0,0 +1,41 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, shrink-to-fit=no"
|
||||
/>
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<!--
|
||||
manifest.json provides metadata used when your web app is added to the
|
||||
homescreen on Android. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||
-->
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<!--
|
||||
Notice the use of %PUBLIC_URL% in the tags above.
|
||||
It will be replaced with the URL of the `public` folder during the build.
|
||||
Only files inside the `public` folder can be referenced from the HTML.
|
||||
|
||||
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<title>Prometheus Expression Browser</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<!--
|
||||
This HTML file is a template.
|
||||
If you open it directly in the browser, you will see an empty page.
|
||||
|
||||
You can add webfonts, meta tags, or analytics to this file.
|
||||
The build step will place the bundled scripts into the <body> tag.
|
||||
|
||||
To begin the development, run `npm start` or `yarn start`.
|
||||
To create a production bundle, use `npm run build` or `yarn build`.
|
||||
-->
|
||||
</body>
|
||||
</html>
|
@ -0,0 +1,15 @@
|
||||
{
|
||||
"short_name": "Prometheus UI",
|
||||
"name": "Prometheus Server Web Interface",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
@ -0,0 +1,180 @@
|
||||
body {
|
||||
padding-top: 10px; /* TODO remove */
|
||||
}
|
||||
|
||||
.panel {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.expression-input {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.expression-input textarea {
|
||||
/* font-family: Menlo,Monaco,Consolas,'Courier New',monospace; */
|
||||
resize: none;
|
||||
}
|
||||
|
||||
button.execute-btn {
|
||||
width: 84px;
|
||||
}
|
||||
|
||||
.alert.alert-danger {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.nav-tabs .nav-link {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
border-left: 1px solid #dee2e6;
|
||||
border-right: 1px solid #dee2e6;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.tab-content .alert {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.data-table.table {
|
||||
margin: 10px 0 2px 0;
|
||||
}
|
||||
|
||||
.data-table > tbody > tr > td {
|
||||
padding: 5px 0 5px 8px;
|
||||
font-size: 0.8em;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.autosuggest-dropdown {
|
||||
position: absolute;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: .25rem;
|
||||
background-color: #fff;
|
||||
color: #495057;
|
||||
font-size: 1rem;
|
||||
z-index: 1000;
|
||||
min-width: 10rem;
|
||||
top: 100%;
|
||||
left: 56px;
|
||||
float: left;
|
||||
padding: .5rem 1px .5rem 1px;
|
||||
margin: -5px;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.autosuggest-dropdown li {
|
||||
width: 100%;
|
||||
padding: .25rem 1.5rem;
|
||||
clear: both;
|
||||
white-space: nowrap;
|
||||
background-color: transparent;
|
||||
border: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.graph-controls, .table-controls {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.graph-controls input {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.graph-controls .range-input input {
|
||||
width: 50px;
|
||||
}
|
||||
|
||||
.graph-controls .time-input input {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
div.time-input {
|
||||
width: 240px !important;
|
||||
}
|
||||
|
||||
.table-controls input {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.graph-controls input.resolution-input {
|
||||
width: 90px;
|
||||
}
|
||||
|
||||
.graph-controls .time-input, .graph-controls .resolution-input, .graph-controls .stacked-input {
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
.graph-controls .clear-time-btn {
|
||||
background: #fff;
|
||||
border-left: none;
|
||||
border-top: 1px solid #ced4da;
|
||||
border-bottom: 1px solid #ced4da;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.graph-legend {
|
||||
margin: 15px 0 15px 25px;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.graph-legend .legend-swatch {
|
||||
padding: 5px;
|
||||
height: 5px;
|
||||
outline-offset: 1px;
|
||||
outline: 1.5px solid #ccc;
|
||||
margin: 2px 8px 2px 0;
|
||||
}
|
||||
|
||||
.legend-metric-name {
|
||||
margin-right: 1px;
|
||||
}
|
||||
|
||||
.legend-label-name {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.graph {
|
||||
margin: 0 5px 0 5px;
|
||||
}
|
||||
|
||||
.graph-chart {
|
||||
height: 500px;
|
||||
width: 100%;
|
||||
/* This is picked up by Flot's axis label font renderer,
|
||||
which ignores "color" and uses "fill" instead. */
|
||||
fill: #495057;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.graph-chart .flot-overlay {
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.graph-tooltip {
|
||||
background: rgba(0,0,0,.8);
|
||||
color: #fff;
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
padding: 8px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.graph-tooltip .labels {
|
||||
font-size: 11px;
|
||||
line-height: 11px;
|
||||
}
|
||||
|
||||
.graph-tooltip .detail-swatch {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
margin: 0 5px 0 0;
|
||||
}
|
||||
|
||||
.add-panel-btn {
|
||||
margin-bottom: 20px;
|
||||
}
|
@ -0,0 +1,10 @@
|
||||
import './globals';
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import App from './App';
|
||||
|
||||
it('renders without crashing', () => {
|
||||
const div = document.createElement('div');
|
||||
ReactDOM.render(<App />, div);
|
||||
ReactDOM.unmountComponentAtNode(div);
|
||||
});
|
@ -0,0 +1,19 @@
|
||||
import React, { Component, ReactNode } from 'react';
|
||||
|
||||
import { Container } from 'reactstrap';
|
||||
|
||||
import PanelList from './PanelList';
|
||||
|
||||
import './App.css';
|
||||
|
||||
class App extends Component {
|
||||
render() {
|
||||
return (
|
||||
<Container fluid={true}>
|
||||
<PanelList />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default App;
|
@ -0,0 +1,107 @@
|
||||
import React, { PureComponent, ReactNode } from 'react';
|
||||
|
||||
import { Alert, Table } from 'reactstrap';
|
||||
|
||||
import SeriesName from './SeriesName';
|
||||
|
||||
export interface QueryResult {
|
||||
data: null | {
|
||||
resultType: 'vector',
|
||||
result: InstantSample[],
|
||||
} | {
|
||||
resultType: 'matrix',
|
||||
result: RangeSamples[],
|
||||
} | {
|
||||
resultType: 'scalar',
|
||||
result: SampleValue,
|
||||
} | {
|
||||
resultType: 'string',
|
||||
result: string,
|
||||
},
|
||||
};
|
||||
|
||||
interface InstantSample {
|
||||
metric: Metric,
|
||||
value: SampleValue,
|
||||
}
|
||||
|
||||
interface RangeSamples {
|
||||
metric: Metric,
|
||||
values: SampleValue[],
|
||||
}
|
||||
|
||||
interface Metric {
|
||||
[key: string]: string,
|
||||
}
|
||||
|
||||
type SampleValue = [number, string];
|
||||
|
||||
class DataTable extends PureComponent<QueryResult> {
|
||||
limitSeries(series: InstantSample[] | RangeSamples[]): InstantSample[] | RangeSamples[] {
|
||||
const maxSeries = 10000;
|
||||
|
||||
if (series.length > maxSeries) {
|
||||
return series.slice(0, maxSeries);
|
||||
}
|
||||
return series;
|
||||
}
|
||||
|
||||
render() {
|
||||
const data = this.props.data;
|
||||
|
||||
if (data === null) {
|
||||
return <Alert color="light">No data queried yet</Alert>;
|
||||
}
|
||||
|
||||
if (data.result === null || data.result.length === 0) {
|
||||
return <Alert color="secondary">Empty query result</Alert>;
|
||||
}
|
||||
|
||||
let rows: ReactNode[] = [];
|
||||
let limited = false;
|
||||
switch(data.resultType) {
|
||||
case 'vector':
|
||||
rows = (this.limitSeries(data.result) as InstantSample[])
|
||||
.map((s: InstantSample, index: number): ReactNode => {
|
||||
return <tr key={index}><td><SeriesName labels={s.metric} format={false}/></td><td>{s.value[1]}</td></tr>;
|
||||
});
|
||||
limited = rows.length != data.result.length;
|
||||
break;
|
||||
case 'matrix':
|
||||
rows = (this.limitSeries(data.result) as RangeSamples[])
|
||||
.map((s, index) => {
|
||||
const valueText = s.values.map((v) => {
|
||||
return [1] + ' @' + v[0];
|
||||
}).join('\n');
|
||||
return <tr style={{whiteSpace: 'pre'}} key={index}><td><SeriesName labels={s.metric} format={false}/></td><td>{valueText}</td></tr>;
|
||||
});
|
||||
limited = rows.length != data.result.length;
|
||||
break;
|
||||
case 'scalar':
|
||||
rows.push(<tr><td>scalar</td><td>{data.result[1]}</td></tr>);
|
||||
break;
|
||||
case 'string':
|
||||
rows.push(<tr><td>scalar</td><td>{data.result[1]}</td></tr>);
|
||||
break;
|
||||
default:
|
||||
return <Alert color="danger">Unsupported result value type</Alert>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{limited &&
|
||||
<Alert color="danger">
|
||||
<strong>Warning:</strong> Fetched {data.result.length} metrics, only displaying first {rows.length}.
|
||||
</Alert>
|
||||
}
|
||||
<Table hover size="sm" className="data-table">
|
||||
<tbody>
|
||||
{rows}
|
||||
</tbody>
|
||||
</Table>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default DataTable;
|
@ -0,0 +1,159 @@
|
||||
import $ from 'jquery';
|
||||
import React, { Component } from 'react';
|
||||
import {
|
||||
Button,
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupText,
|
||||
Input,
|
||||
} from 'reactstrap';
|
||||
|
||||
import Downshift, { ChildrenFunction, ControllerStateAndHelpers, DownshiftInterface } from 'downshift';
|
||||
import fuzzy from 'fuzzy';
|
||||
|
||||
import { library } from '@fortawesome/fontawesome-svg-core';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faSearch, faSpinner } from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
library.add(faSearch, faSpinner);
|
||||
|
||||
interface ExpressionInputProps {
|
||||
value: string;
|
||||
metricNames: string[];
|
||||
executeQuery: (expr: string) => void;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
class ExpressionInput extends Component<ExpressionInputProps> {
|
||||
prevNoMatchValue: string | null = null;
|
||||
private exprInputRef = React.createRef<HTMLInputElement>();
|
||||
|
||||
handleKeyPress = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
this.props.executeQuery(this.exprInputRef.current!.value);
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
renderAutosuggest = (downshift: any) => {
|
||||
if (!downshift.isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (this.prevNoMatchValue && downshift.inputValue.includes(this.prevNoMatchValue)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let matches = fuzzy.filter(downshift.inputValue.replace(/ /g, ''), this.props.metricNames, {
|
||||
pre: "<strong>",
|
||||
post: "</strong>",
|
||||
});
|
||||
|
||||
if (matches.length === 0) {
|
||||
this.prevNoMatchValue = downshift.inputValue;
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="autosuggest-dropdown" {...downshift.getMenuProps()}>
|
||||
{
|
||||
matches
|
||||
.slice(0, 200) // Limit DOM rendering to 100 results, as DOM rendering is sloooow.
|
||||
.map((item, index) => (
|
||||
<li
|
||||
{...downshift.getItemProps({
|
||||
key: item.original,
|
||||
index,
|
||||
item: item.original,
|
||||
style: {
|
||||
backgroundColor:
|
||||
downshift.highlightedIndex === index ? 'lightgray' : 'white',
|
||||
fontWeight: downshift.selectedItem === item ? 'bold' : 'normal',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{/* TODO: Find better way than setting inner HTML dangerously. We just want the <strong> to not be escaped.
|
||||
This will be a problem when we save history and the user enters HTML into a query. */}
|
||||
<span dangerouslySetInnerHTML={{__html: item.string}}></span>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const $exprInput = $(this.exprInputRef.current!);
|
||||
const resize = () => {
|
||||
const el = $exprInput.get(0);
|
||||
const offset = el.offsetHeight - el.clientHeight;
|
||||
$exprInput.css('height', 'auto').css('height', el.scrollHeight + offset);
|
||||
};
|
||||
resize();
|
||||
$exprInput.on('input', resize);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Downshift
|
||||
//inputValue={this.props.value}
|
||||
//onInputValueChange={this.props.onChange}
|
||||
selectedItem={this.props.value}
|
||||
>
|
||||
{(downshift) => (
|
||||
<div>
|
||||
<InputGroup className="expression-input">
|
||||
<InputGroupAddon addonType="prepend">
|
||||
<InputGroupText>
|
||||
{this.props.loading ? <FontAwesomeIcon icon="spinner" spin/> : <FontAwesomeIcon icon="search"/>}
|
||||
</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
|
||||
<Input
|
||||
autoFocus
|
||||
type="textarea"
|
||||
rows="1"
|
||||
onKeyPress={this.handleKeyPress}
|
||||
placeholder="Expression (press Shift+Enter for newlines)"
|
||||
innerRef={this.exprInputRef}
|
||||
{...downshift.getInputProps({
|
||||
onKeyDown: (event: React.KeyboardEvent): void => {
|
||||
switch (event.key) {
|
||||
case 'Home':
|
||||
case 'End':
|
||||
// We want to be able to jump to the beginning/end of the input field.
|
||||
// By default, Downshift otherwise jumps to the first/last suggestion item instead.
|
||||
(event.nativeEvent as any).preventDownshiftDefault = true;
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
case 'ArrowDown':
|
||||
if (!downshift.isOpen) {
|
||||
(event.nativeEvent as any).preventDownshiftDefault = true;
|
||||
}
|
||||
break;
|
||||
case 'Enter':
|
||||
downshift.closeMenu();
|
||||
break;
|
||||
case 'Escape':
|
||||
if (!downshift.isOpen) {
|
||||
this.exprInputRef.current!.blur();
|
||||
}
|
||||
break;
|
||||
default:
|
||||
}
|
||||
}
|
||||
} as any)}
|
||||
/>
|
||||
<InputGroupAddon addonType="append">
|
||||
<Button className="execute-btn" color="primary" onClick={() => this.props.executeQuery(this.exprInputRef.current!.value)}>Execute</Button>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
{this.renderAutosuggest(downshift)}
|
||||
</div>
|
||||
)}
|
||||
</Downshift>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default ExpressionInput;
|
@ -0,0 +1,285 @@
|
||||
import $ from 'jquery';
|
||||
import React, { PureComponent } from 'react';
|
||||
import ReactResizeDetector from 'react-resize-detector';
|
||||
|
||||
import { Alert } from 'reactstrap';
|
||||
|
||||
require('flot');
|
||||
require('flot/source/jquery.flot.crosshair');
|
||||
require('flot/source/jquery.flot.legend');
|
||||
require('flot/source/jquery.flot.time');
|
||||
require('flot/source/jquery.canvaswrapper');
|
||||
require('jquery.flot.tooltip');
|
||||
|
||||
import Legend from './Legend';
|
||||
|
||||
var graphID = 0;
|
||||
function getGraphID() {
|
||||
// TODO: This is ugly.
|
||||
return graphID++;
|
||||
}
|
||||
|
||||
interface GraphProps {
|
||||
data: any; // TODO: Type this.
|
||||
stacked: boolean;
|
||||
queryParams: {
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
resolution: number,
|
||||
} | null;
|
||||
}
|
||||
|
||||
class Graph extends PureComponent<GraphProps> {
|
||||
private id: number = getGraphID();
|
||||
private chartRef = React.createRef<HTMLDivElement>();
|
||||
|
||||
escapeHTML(str: string) {
|
||||
var entityMap: {[key: string]: string} = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
'/': '/'
|
||||
};
|
||||
|
||||
return String(str).replace(/[&<>"'/]/g, function (s) {
|
||||
return entityMap[s];
|
||||
});
|
||||
}
|
||||
|
||||
renderLabels(labels: {[key: string]: string}) {
|
||||
let labelStrings: string[] = [];
|
||||
for (let label in labels) {
|
||||
if (label !== '__name__') {
|
||||
labelStrings.push('<strong>' + label + '</strong>: ' + this.escapeHTML(labels[label]));
|
||||
}
|
||||
}
|
||||
return '<div class="labels">' + labelStrings.join('<br>') + '</div>';
|
||||
};
|
||||
|
||||
formatValue = (y: number | null): string => {
|
||||
if (y === null) {
|
||||
return 'null';
|
||||
}
|
||||
var abs_y = Math.abs(y);
|
||||
if (abs_y >= 1e24) {
|
||||
return (y / 1e24).toFixed(2) + "Y";
|
||||
} else if (abs_y >= 1e21) {
|
||||
return (y / 1e21).toFixed(2) + "Z";
|
||||
} else if (abs_y >= 1e18) {
|
||||
return (y / 1e18).toFixed(2) + "E";
|
||||
} else if (abs_y >= 1e15) {
|
||||
return (y / 1e15).toFixed(2) + "P";
|
||||
} else if (abs_y >= 1e12) {
|
||||
return (y / 1e12).toFixed(2) + "T";
|
||||
} else if (abs_y >= 1e9) {
|
||||
return (y / 1e9).toFixed(2) + "G";
|
||||
} else if (abs_y >= 1e6) {
|
||||
return (y / 1e6).toFixed(2) + "M";
|
||||
} else if (abs_y >= 1e3) {
|
||||
return (y / 1e3).toFixed(2) + "k";
|
||||
} else if (abs_y >= 1) {
|
||||
return y.toFixed(2)
|
||||
} else if (abs_y === 0) {
|
||||
return y.toFixed(2)
|
||||
} else if (abs_y <= 1e-24) {
|
||||
return (y / 1e-24).toFixed(2) + "y";
|
||||
} else if (abs_y <= 1e-21) {
|
||||
return (y / 1e-21).toFixed(2) + "z";
|
||||
} else if (abs_y <= 1e-18) {
|
||||
return (y / 1e-18).toFixed(2) + "a";
|
||||
} else if (abs_y <= 1e-15) {
|
||||
return (y / 1e-15).toFixed(2) + "f";
|
||||
} else if (abs_y <= 1e-12) {
|
||||
return (y / 1e-12).toFixed(2) + "p";
|
||||
} else if (abs_y <= 1e-9) {
|
||||
return (y / 1e-9).toFixed(2) + "n";
|
||||
} else if (abs_y <= 1e-6) {
|
||||
return (y / 1e-6).toFixed(2) + "µ";
|
||||
} else if (abs_y <=1e-3) {
|
||||
return (y / 1e-3).toFixed(2) + "m";
|
||||
} else if (abs_y <= 1) {
|
||||
return y.toFixed(2)
|
||||
}
|
||||
throw Error("couldn't format a value, this is a bug");
|
||||
}
|
||||
|
||||
getOptions(): any {
|
||||
return {
|
||||
grid: {
|
||||
hoverable: true,
|
||||
clickable: true,
|
||||
autoHighlight: true,
|
||||
mouseActiveRadius: 100,
|
||||
},
|
||||
legend: {
|
||||
show: false,
|
||||
},
|
||||
xaxis: {
|
||||
mode: 'time',
|
||||
showTicks: true,
|
||||
showMinorTicks: true,
|
||||
timeBase: 'milliseconds',
|
||||
},
|
||||
yaxis: {
|
||||
tickFormatter: this.formatValue,
|
||||
},
|
||||
crosshair: {
|
||||
mode: 'xy',
|
||||
color: '#bbb',
|
||||
},
|
||||
tooltip: {
|
||||
show: true,
|
||||
cssClass: 'graph-tooltip',
|
||||
content: (label: string, xval: number, yval: number, flotItem: any) => {
|
||||
const series = flotItem.series; // TODO: type this.
|
||||
var date = '<span class="date">' + new Date(xval).toUTCString() + '</span>';
|
||||
var swatch = '<span class="detail-swatch" style="background-color: ' + series.color + '"></span>';
|
||||
var content = swatch + (series.labels.__name__ || 'value') + ": <strong>" + yval + '</strong>';
|
||||
return date + '<br>' + content + '<br>' + this.renderLabels(series.labels);
|
||||
},
|
||||
defaultTheme: false,
|
||||
lines: true,
|
||||
},
|
||||
series: {
|
||||
stack: this.props.stacked,
|
||||
lines: {
|
||||
lineWidth: this.props.stacked ? 1 : 2,
|
||||
steps: false,
|
||||
fill: this.props.stacked,
|
||||
},
|
||||
shadowSize: 0,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// This was adapted from Flot's color generation code.
|
||||
getColors() {
|
||||
let colors = [];
|
||||
const colorPool = ["#edc240", "#afd8f8", "#cb4b4b", "#4da74d", "#9440ed"];
|
||||
const colorPoolSize = colorPool.length;
|
||||
let variation = 0;
|
||||
const neededColors = this.props.data.result.length;
|
||||
|
||||
for (let i = 0; i < neededColors; i++) {
|
||||
const c = ($ as any).color.parse(colorPool[i % colorPoolSize] || "#666");
|
||||
|
||||
// Each time we exhaust the colors in the pool we adjust
|
||||
// a scaling factor used to produce more variations on
|
||||
// those colors. The factor alternates negative/positive
|
||||
// to produce lighter/darker colors.
|
||||
|
||||
// Reset the variation after every few cycles, or else
|
||||
// it will end up producing only white or black colors.
|
||||
|
||||
if (i % colorPoolSize === 0 && i) {
|
||||
if (variation >= 0) {
|
||||
if (variation < 0.5) {
|
||||
variation = -variation - 0.2;
|
||||
} else variation = 0;
|
||||
} else variation = -variation;
|
||||
}
|
||||
|
||||
colors[i] = c.scale('rgb', 1 + variation);
|
||||
}
|
||||
|
||||
return colors;
|
||||
}
|
||||
|
||||
getData() {
|
||||
const colors = this.getColors();
|
||||
|
||||
return this.props.data.result.map((ts: any /* TODO: Type this*/, index: number) => {
|
||||
// Insert nulls for all missing steps.
|
||||
let data = [];
|
||||
let pos = 0;
|
||||
const params = this.props.queryParams!;
|
||||
|
||||
for (let t = params.startTime; t <= params.endTime; t += params.resolution) {
|
||||
// Allow for floating point inaccuracy.
|
||||
if (ts.values.length > pos && ts.values[pos][0] < t + params.resolution / 100) {
|
||||
data.push([ts.values[pos][0] * 1000, this.parseValue(ts.values[pos][1])]);
|
||||
pos++;
|
||||
} else {
|
||||
// TODO: Flot has problems displaying intermittent "null" values when stacked,
|
||||
// resort to 0 now. In Grafana this works for some reason, figure out how they
|
||||
// do it.
|
||||
data.push([t * 1000, this.props.stacked ? 0 : null]);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
labels: ts.metric !== null ? ts.metric : {},
|
||||
data: data,
|
||||
color: colors[index],
|
||||
index: index,
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
parseValue(value: string) {
|
||||
var val = parseFloat(value);
|
||||
if (isNaN(val)) {
|
||||
// "+Inf", "-Inf", "+Inf" will be parsed into NaN by parseFloat(). They
|
||||
// can't be graphed, so show them as gaps (null).
|
||||
|
||||
// TODO: Flot has problems displaying intermittent "null" values when stacked,
|
||||
// resort to 0 now. In Grafana this works for some reason, figure out how they
|
||||
// do it.
|
||||
return this.props.stacked ? 0 : null;
|
||||
}
|
||||
return val;
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.plot();
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
this.plot();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.destroyPlot();
|
||||
}
|
||||
|
||||
plot() {
|
||||
if (this.chartRef.current === null) {
|
||||
return;
|
||||
}
|
||||
this.destroyPlot();
|
||||
$.plot($(this.chartRef.current!), this.getData(), this.getOptions());
|
||||
}
|
||||
|
||||
destroyPlot() {
|
||||
const chart = $(this.chartRef.current!).data('plot');
|
||||
if (chart !== undefined) {
|
||||
chart.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.props.data === null) {
|
||||
return <Alert color="light">No data queried yet</Alert>;
|
||||
}
|
||||
|
||||
if (this.props.data.resultType !== 'matrix') {
|
||||
return <Alert color="danger">Query result is of wrong type '{this.props.data.resultType}', should be 'matrix' (range vector).</Alert>;
|
||||
}
|
||||
|
||||
if (this.props.data.result.length === 0) {
|
||||
return <Alert color="secondary">Empty query result</Alert>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="graph">
|
||||
<ReactResizeDetector handleWidth onResize={() => this.plot()} />
|
||||
<div className="graph-chart" ref={this.chartRef} />
|
||||
<Legend series={this.getData()}/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Graph;
|
@ -0,0 +1,156 @@
|
||||
import React, { Component } from 'react';
|
||||
import {
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Form,
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
Input,
|
||||
} from 'reactstrap';
|
||||
|
||||
import { library } from '@fortawesome/fontawesome-svg-core';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
faPlus,
|
||||
faMinus,
|
||||
faChartArea,
|
||||
faChartLine,
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
import TimeInput from './TimeInput';
|
||||
import { parseRange, formatRange } from './utils/timeFormat';
|
||||
|
||||
library.add(
|
||||
faPlus,
|
||||
faMinus,
|
||||
faChartArea,
|
||||
faChartLine,
|
||||
);
|
||||
|
||||
interface GraphControlsProps {
|
||||
range: number;
|
||||
endTime: number | null;
|
||||
resolution: number | null;
|
||||
stacked: boolean;
|
||||
|
||||
onChangeRange: (range: number) => void;
|
||||
onChangeEndTime: (endTime: number | null) => void;
|
||||
onChangeResolution: (resolution: number | null) => void;
|
||||
onChangeStacking: (stacked: boolean) => void;
|
||||
}
|
||||
|
||||
class GraphControls extends Component<GraphControlsProps> {
|
||||
private rangeRef = React.createRef<HTMLInputElement>();
|
||||
private resolutionRef = React.createRef<HTMLInputElement>();
|
||||
|
||||
rangeSteps = [
|
||||
1,
|
||||
10,
|
||||
60,
|
||||
5*60,
|
||||
15*60,
|
||||
30*60,
|
||||
60*60,
|
||||
2*60*60,
|
||||
6*60*60,
|
||||
12*60*60,
|
||||
24*60*60,
|
||||
48*60*60,
|
||||
7*24*60*60,
|
||||
14*24*60*60,
|
||||
28*24*60*60,
|
||||
56*24*60*60,
|
||||
365*24*60*60,
|
||||
730*24*60*60,
|
||||
]
|
||||
|
||||
onChangeRangeInput = (rangeText: string): void => {
|
||||
const range = parseRange(rangeText);
|
||||
if (range === null) {
|
||||
this.changeRangeInput(this.props.range);
|
||||
} else {
|
||||
this.props.onChangeRange(range);
|
||||
}
|
||||
}
|
||||
|
||||
changeRangeInput = (range: number): void => {
|
||||
this.rangeRef.current!.value = formatRange(range);
|
||||
}
|
||||
|
||||
increaseRange = (): void => {
|
||||
for (let range of this.rangeSteps) {
|
||||
if (this.props.range < range) {
|
||||
this.changeRangeInput(range);
|
||||
this.props.onChangeRange(range);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
decreaseRange = (): void => {
|
||||
for (let range of this.rangeSteps.slice().reverse()) {
|
||||
if (this.props.range > range) {
|
||||
this.changeRangeInput(range);
|
||||
this.props.onChangeRange(range);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: GraphControlsProps) {
|
||||
if (prevProps.range !== this.props.range) {
|
||||
this.changeRangeInput(this.props.range);
|
||||
}
|
||||
if (prevProps.resolution !== this.props.resolution) {
|
||||
this.resolutionRef.current!.value = this.props.resolution !== null ? this.props.resolution.toString() : '';
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Form inline className="graph-controls" onSubmit={e => e.preventDefault()}>
|
||||
<InputGroup className="range-input" size="sm">
|
||||
<InputGroupAddon addonType="prepend">
|
||||
<Button title="Decrease range" onClick={this.decreaseRange}><FontAwesomeIcon icon="minus" fixedWidth/></Button>
|
||||
</InputGroupAddon>
|
||||
|
||||
<Input
|
||||
defaultValue={formatRange(this.props.range)}
|
||||
innerRef={this.rangeRef}
|
||||
onBlur={() => this.onChangeRangeInput(this.rangeRef.current!.value)}
|
||||
/>
|
||||
|
||||
<InputGroupAddon addonType="append">
|
||||
<Button title="Increase range" onClick={this.increaseRange}><FontAwesomeIcon icon="plus" fixedWidth/></Button>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
|
||||
<TimeInput
|
||||
time={this.props.endTime}
|
||||
range={this.props.range}
|
||||
placeholder="End time"
|
||||
onChangeTime={this.props.onChangeEndTime}
|
||||
/>
|
||||
|
||||
<Input
|
||||
placeholder="Res. (s)"
|
||||
className="resolution-input"
|
||||
defaultValue={this.props.resolution !== null ? this.props.resolution.toString() : ''}
|
||||
innerRef={this.resolutionRef}
|
||||
onBlur={() => {
|
||||
const res = parseInt(this.resolutionRef.current!.value);
|
||||
this.props.onChangeResolution(res ? res : null);
|
||||
}}
|
||||
bsSize="sm"
|
||||
/>
|
||||
|
||||
<ButtonGroup className="stacked-input" size="sm">
|
||||
<Button title="Show unstacked line graph" onClick={() => this.props.onChangeStacking(false)} active={!this.props.stacked}><FontAwesomeIcon icon="chart-line" fixedWidth/></Button>
|
||||
<Button title="Show stacked graph" onClick={() => this.props.onChangeStacking(true)} active={this.props.stacked}><FontAwesomeIcon icon="chart-area" fixedWidth/></Button>
|
||||
</ButtonGroup>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default GraphControls;
|
@ -0,0 +1,34 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
|
||||
import SeriesName from './SeriesName';
|
||||
|
||||
interface LegendProps {
|
||||
series: any; // TODO: Type this.
|
||||
}
|
||||
|
||||
class Legend extends PureComponent<LegendProps> {
|
||||
renderLegendItem(s: any) {
|
||||
return (
|
||||
<tr key={s.index} className="legend-item">
|
||||
<td>
|
||||
<div className="legend-swatch" style={{backgroundColor: s.color}}></div>
|
||||
</td>
|
||||
<td>
|
||||
<SeriesName labels={s.labels} format={true} />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<table className="graph-legend">
|
||||
<tbody>
|
||||
{this.props.series.map((s: any) => {return this.renderLegendItem(s)})}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Legend;
|
@ -0,0 +1,16 @@
|
||||
function metricToSeriesName(labels: {[key: string]: string}): string {
|
||||
if (labels === null) {
|
||||
return 'scalar';
|
||||
}
|
||||
let tsName = (labels.__name__ || '') + '{';
|
||||
let labelStrings: string[] = [];
|
||||
for (let label in labels) {
|
||||
if (label !== '__name__') {
|
||||
labelStrings.push(label + '="' + labels[label] + '"');
|
||||
}
|
||||
}
|
||||
tsName += labelStrings.join(', ') + '}';
|
||||
return tsName;
|
||||
};
|
||||
|
||||
export default metricToSeriesName;
|
@ -0,0 +1,297 @@
|
||||
import React, { Component } from 'react';
|
||||
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Col,
|
||||
Nav,
|
||||
NavItem,
|
||||
NavLink,
|
||||
Row,
|
||||
TabContent,
|
||||
TabPane,
|
||||
} from 'reactstrap';
|
||||
|
||||
import moment from 'moment-timezone';
|
||||
|
||||
import ExpressionInput from './ExpressionInput';
|
||||
import GraphControls from './GraphControls';
|
||||
import Graph from './Graph';
|
||||
import DataTable from './DataTable';
|
||||
import TimeInput from './TimeInput';
|
||||
|
||||
interface PanelProps {
|
||||
options: PanelOptions;
|
||||
onOptionsChanged: (opts: PanelOptions) => void;
|
||||
metricNames: string[];
|
||||
removePanel: () => void;
|
||||
}
|
||||
|
||||
interface PanelState {
|
||||
data: any; // TODO: Type data.
|
||||
lastQueryParams: { // TODO: Share these with Graph.tsx in a file.
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
resolution: number,
|
||||
} | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
stats: null; // TODO: Stats.
|
||||
}
|
||||
|
||||
export interface PanelOptions {
|
||||
expr: string;
|
||||
type: PanelType;
|
||||
range: number; // Range in seconds.
|
||||
endTime: number | null; // Timestamp in milliseconds.
|
||||
resolution: number | null; // Resolution in seconds.
|
||||
stacked: boolean;
|
||||
}
|
||||
|
||||
export enum PanelType {
|
||||
Graph = 'graph',
|
||||
Table = 'table',
|
||||
}
|
||||
|
||||
export const PanelDefaultOptions: PanelOptions = {
|
||||
type: PanelType.Table,
|
||||
expr: '',
|
||||
range: 3600,
|
||||
endTime: null,
|
||||
resolution: null,
|
||||
stacked: false,
|
||||
}
|
||||
|
||||
class Panel extends Component<PanelProps, PanelState> {
|
||||
private abortInFlightFetch: (() => void) | null = null;
|
||||
|
||||
constructor(props: PanelProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
data: null,
|
||||
lastQueryParams: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
stats: null,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: PanelProps, prevState: PanelState) {
|
||||
const prevOpts = prevProps.options;
|
||||
const opts = this.props.options;
|
||||
if (prevOpts.type !== opts.type ||
|
||||
prevOpts.range !== opts.range ||
|
||||
prevOpts.endTime !== opts.endTime ||
|
||||
prevOpts.resolution !== opts.resolution) {
|
||||
|
||||
if (prevOpts.type !== opts.type) {
|
||||
// If the other options change, we still want to show the old data until the new
|
||||
// query completes, but this is not a good idea when we actually change between
|
||||
// table and graph view, since not all queries work well in both.
|
||||
this.setState({data: null});
|
||||
}
|
||||
this.executeQuery(opts.expr);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.executeQuery(this.props.options.expr);
|
||||
}
|
||||
|
||||
executeQuery = (expr: string): void => {
|
||||
if (this.props.options.expr !== expr) {
|
||||
this.setOptions({expr: expr});
|
||||
}
|
||||
if (expr === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.abortInFlightFetch) {
|
||||
this.abortInFlightFetch();
|
||||
this.abortInFlightFetch = null;
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
this.abortInFlightFetch = () => abortController.abort();
|
||||
this.setState({loading: true});
|
||||
|
||||
const endTime = this.getEndTime().valueOf() / 1000; // TODO: shouldn't valueof only work when it's a moment?
|
||||
const startTime = endTime - this.props.options.range;
|
||||
const resolution = this.props.options.resolution || Math.max(Math.floor(this.props.options.range / 250), 1);
|
||||
|
||||
const url = new URL(window.location.href);
|
||||
const params: {[key: string]: string} = {
|
||||
'query': expr,
|
||||
};
|
||||
|
||||
switch (this.props.options.type) {
|
||||
case 'graph':
|
||||
url.pathname = '../../api/v1/query_range'
|
||||
Object.assign(params, {
|
||||
start: startTime,
|
||||
end: endTime,
|
||||
step: resolution,
|
||||
})
|
||||
// TODO path prefix here and elsewhere.
|
||||
break;
|
||||
case 'table':
|
||||
url.pathname = '../../api/v1/query'
|
||||
Object.assign(params, {
|
||||
time: endTime,
|
||||
})
|
||||
break;
|
||||
default:
|
||||
throw new Error('Invalid panel type "' + this.props.options.type + '"');
|
||||
}
|
||||
Object.keys(params).forEach(key => url.searchParams.append(key, params[key]))
|
||||
|
||||
fetch(url.toString(), {cache: 'no-store', signal: abortController.signal})
|
||||
.then(resp => resp.json())
|
||||
.then(json => {
|
||||
if (json.status !== 'success') {
|
||||
throw new Error(json.error || 'invalid response JSON');
|
||||
}
|
||||
|
||||
this.setState({
|
||||
error: null,
|
||||
data: json.data,
|
||||
lastQueryParams: {
|
||||
startTime: startTime,
|
||||
endTime: endTime,
|
||||
resolution: resolution,
|
||||
},
|
||||
loading: false,
|
||||
});
|
||||
this.abortInFlightFetch = null;
|
||||
})
|
||||
.catch(error => {
|
||||
if (error.name === 'AbortError') {
|
||||
// Aborts are expected, don't show an error for them.
|
||||
return
|
||||
}
|
||||
this.setState({
|
||||
error: 'Error executing query: ' + error.message,
|
||||
loading: false,
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
setOptions(opts: object): void {
|
||||
const newOpts = {...this.props.options, ...opts};
|
||||
this.props.onOptionsChanged(newOpts);
|
||||
}
|
||||
|
||||
handleExpressionChange = (expr: string): void => {
|
||||
this.setOptions({expr: expr});
|
||||
}
|
||||
|
||||
handleChangeRange = (range: number): void => {
|
||||
this.setOptions({range: range});
|
||||
}
|
||||
|
||||
getEndTime = (): number | moment.Moment => {
|
||||
if (this.props.options.endTime === null) {
|
||||
return moment();
|
||||
}
|
||||
return this.props.options.endTime;
|
||||
}
|
||||
|
||||
handleChangeEndTime = (endTime: number | null) => {
|
||||
this.setOptions({endTime: endTime});
|
||||
}
|
||||
|
||||
handleChangeResolution = (resolution: number | null) => {
|
||||
this.setOptions({resolution: resolution});
|
||||
}
|
||||
|
||||
handleChangeStacking = (stacked: boolean) => {
|
||||
this.setOptions({stacked: stacked});
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="panel">
|
||||
<Row>
|
||||
<Col>
|
||||
<ExpressionInput
|
||||
value={this.props.options.expr}
|
||||
executeQuery={this.executeQuery}
|
||||
loading={this.state.loading}
|
||||
metricNames={this.props.metricNames}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Col>
|
||||
{this.state.error && <Alert color="danger">{this.state.error}</Alert>}
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Col>
|
||||
<Nav tabs>
|
||||
<NavItem>
|
||||
<NavLink
|
||||
className={this.props.options.type === 'table' ? 'active' : ''}
|
||||
onClick={() => { this.setOptions({type: 'table'}); }}
|
||||
>
|
||||
Table
|
||||
</NavLink>
|
||||
</NavItem>
|
||||
<NavItem>
|
||||
<NavLink
|
||||
className={this.props.options.type === 'graph' ? 'active' : ''}
|
||||
onClick={() => { this.setOptions({type: 'graph'}); }}
|
||||
>
|
||||
Graph
|
||||
</NavLink>
|
||||
</NavItem>
|
||||
</Nav>
|
||||
<TabContent activeTab={this.props.options.type}>
|
||||
<TabPane tabId="table">
|
||||
{this.props.options.type === 'table' &&
|
||||
<>
|
||||
<div className="table-controls">
|
||||
<TimeInput
|
||||
time={this.props.options.endTime}
|
||||
range={this.props.options.range}
|
||||
placeholder="Evaluation time"
|
||||
onChangeTime={this.handleChangeEndTime}
|
||||
/>
|
||||
</div>
|
||||
<DataTable data={this.state.data} />
|
||||
</>
|
||||
}
|
||||
</TabPane>
|
||||
<TabPane tabId="graph">
|
||||
{this.props.options.type === 'graph' &&
|
||||
<>
|
||||
<GraphControls
|
||||
range={this.props.options.range}
|
||||
endTime={this.props.options.endTime}
|
||||
resolution={this.props.options.resolution}
|
||||
stacked={this.props.options.stacked}
|
||||
|
||||
onChangeRange={this.handleChangeRange}
|
||||
onChangeEndTime={this.handleChangeEndTime}
|
||||
onChangeResolution={this.handleChangeResolution}
|
||||
onChangeStacking={this.handleChangeStacking}
|
||||
/>
|
||||
<Graph data={this.state.data} stacked={this.props.options.stacked} queryParams={this.state.lastQueryParams} />
|
||||
</>
|
||||
}
|
||||
</TabPane>
|
||||
</TabContent>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Col>
|
||||
<Button className="float-right" color="link" onClick={this.props.removePanel} size="sm">Remove Panel</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Panel;
|
@ -0,0 +1,146 @@
|
||||
import React, { Component } from 'react';
|
||||
|
||||
import { Alert, Button, Col, Row } from 'reactstrap';
|
||||
|
||||
import Panel, { PanelOptions, PanelType, PanelDefaultOptions } from './Panel';
|
||||
import { decodePanelOptionsFromQueryString, encodePanelOptionsToQueryString } from './utils/urlParams';
|
||||
|
||||
interface PanelListState {
|
||||
panels: {
|
||||
key: string;
|
||||
options: PanelOptions;
|
||||
}[],
|
||||
metricNames: string[];
|
||||
fetchMetricsError: string | null;
|
||||
timeDriftError: string | null;
|
||||
}
|
||||
|
||||
class PanelList extends Component<any, PanelListState> {
|
||||
private key: number = 0;
|
||||
|
||||
constructor(props: any) {
|
||||
super(props);
|
||||
|
||||
const urlPanels = decodePanelOptionsFromQueryString(window.location.search);
|
||||
|
||||
this.state = {
|
||||
panels: urlPanels.length !== 0 ? urlPanels : [
|
||||
{
|
||||
key: this.getKey(),
|
||||
options: PanelDefaultOptions,
|
||||
},
|
||||
],
|
||||
metricNames: [],
|
||||
fetchMetricsError: null,
|
||||
timeDriftError: null,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
fetch("../../api/v1/label/__name__/values", {cache: "no-store"})
|
||||
.then(resp => {
|
||||
if (resp.ok) {
|
||||
return resp.json();
|
||||
} else {
|
||||
throw new Error('Unexpected response status when fetching metric names: ' + resp.statusText); // TODO extract error
|
||||
}
|
||||
})
|
||||
.then(json => this.setState({ metricNames: json.data }))
|
||||
.catch(error => this.setState({ fetchMetricsError: error.message }));
|
||||
|
||||
const browserTime = new Date().getTime() / 1000;
|
||||
fetch("../../api/v1/query?query=time()", {cache: "no-store"})
|
||||
.then(resp => {
|
||||
if (resp.ok) {
|
||||
return resp.json();
|
||||
} else {
|
||||
throw new Error('Unexpected response status when fetching metric names: ' + resp.statusText); // TODO extract error
|
||||
}
|
||||
})
|
||||
.then(json => {
|
||||
const serverTime = json.data.result[0];
|
||||
const delta = Math.abs(browserTime - serverTime);
|
||||
|
||||
if (delta >= 30) {
|
||||
throw new Error('Detected ' + delta + ' seconds time difference between your browser and the server. Prometheus relies on accurate time and time drift might cause unexpected query results.');
|
||||
}
|
||||
})
|
||||
.catch(error => this.setState({ timeDriftError: error.message }));
|
||||
|
||||
window.onpopstate = () => {
|
||||
const panels = decodePanelOptionsFromQueryString(window.location.search);
|
||||
if (panels.length !== 0) {
|
||||
this.setState({panels: panels});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getKey(): string {
|
||||
return (this.key++).toString();
|
||||
}
|
||||
|
||||
handleOptionsChanged(key: string, opts: PanelOptions): void {
|
||||
const newPanels = this.state.panels.map(p => {
|
||||
if (key === p.key) {
|
||||
return {
|
||||
key: key,
|
||||
options: opts,
|
||||
}
|
||||
}
|
||||
return p;
|
||||
});
|
||||
console.log("UPDATE OP", key, opts);
|
||||
this.setState({panels: newPanels}, this.updateURL)
|
||||
}
|
||||
|
||||
updateURL(): void {
|
||||
console.log("UPDATE");
|
||||
const query = encodePanelOptionsToQueryString(this.state.panels);
|
||||
history.pushState({}, '', query);
|
||||
}
|
||||
|
||||
addPanel = (): void => {
|
||||
const panels = this.state.panels.slice();
|
||||
panels.push({
|
||||
key: this.getKey(),
|
||||
options: PanelDefaultOptions,
|
||||
});
|
||||
this.setState({panels: panels}, this.updateURL);
|
||||
}
|
||||
|
||||
removePanel = (key: string): void => {
|
||||
const panels = this.state.panels.filter(panel => {
|
||||
return panel.key !== key;
|
||||
});
|
||||
this.setState({panels: panels}, this.updateURL);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<>
|
||||
<Row>
|
||||
<Col>
|
||||
{this.state.timeDriftError && <Alert color="danger"><strong>Warning:</strong> Error fetching server time: {this.state.timeDriftError}</Alert>}
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Col>
|
||||
{this.state.fetchMetricsError && <Alert color="danger"><strong>Warning:</strong> Error fetching metrics list: {this.state.fetchMetricsError}</Alert>}
|
||||
</Col>
|
||||
</Row>
|
||||
{this.state.panels.map(p =>
|
||||
<Panel
|
||||
key={p.key}
|
||||
options={p.options}
|
||||
onOptionsChanged={(opts: PanelOptions) => this.handleOptionsChanged(p.key, opts)}
|
||||
removePanel={() => this.removePanel(p.key)}
|
||||
metricNames={this.state.metricNames}
|
||||
/>
|
||||
)}
|
||||
<Button color="primary" className="add-panel-btn" onClick={this.addPanel}>Add Panel</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default PanelList;
|
@ -0,0 +1,70 @@
|
||||
import React, { PureComponent } from "react";
|
||||
|
||||
interface SeriesNameProps {
|
||||
labels: {[key: string]: string} | null;
|
||||
format: boolean;
|
||||
}
|
||||
|
||||
class SeriesName extends PureComponent<SeriesNameProps> {
|
||||
renderFormatted(): React.ReactNode {
|
||||
const labels = this.props.labels!;
|
||||
|
||||
let labelNodes: React.ReactNode[] = [];
|
||||
let first = true;
|
||||
for (let label in labels) {
|
||||
if (label === '__name__') {
|
||||
continue;
|
||||
}
|
||||
|
||||
labelNodes.push(
|
||||
<span key={label}>
|
||||
{!first && ', '}
|
||||
<span className="legend-label-name">{label}</span>=
|
||||
<span className="legend-label-value">"{labels[label]}"</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
if (first) {
|
||||
first = false;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className="legend-metric-name">{labels.__name__ || ''}</span>
|
||||
<span className="legend-label-brace">{'{'}</span>
|
||||
{labelNodes}
|
||||
<span className="legend-label-brace">{'}'}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
renderPlain() {
|
||||
const labels = this.props.labels!;
|
||||
|
||||
let tsName = (labels.__name__ || '') + '{';
|
||||
let labelStrings: string[] = [];
|
||||
for (let label in labels) {
|
||||
if (label !== '__name__') {
|
||||
labelStrings.push(label + '="' + labels[label] + '"');
|
||||
}
|
||||
}
|
||||
tsName += labelStrings.join(', ') + '}';
|
||||
return tsName;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.props.labels === null) {
|
||||
return 'scalar';
|
||||
}
|
||||
|
||||
if (this.props.format) {
|
||||
return this.renderFormatted();
|
||||
}
|
||||
// Return a simple text node. This is much faster to scroll through
|
||||
// for longer lists (hundreds of items).
|
||||
return this.renderPlain();
|
||||
}
|
||||
}
|
||||
|
||||
export default SeriesName;
|
@ -0,0 +1,130 @@
|
||||
import $ from 'jquery';
|
||||
import React, { Component } from 'react';
|
||||
import { Button, InputGroup, InputGroupAddon, Input } from 'reactstrap';
|
||||
|
||||
import moment from 'moment-timezone';
|
||||
|
||||
import 'tempusdominus-core';
|
||||
import 'tempusdominus-bootstrap-4';
|
||||
import '../node_modules/tempusdominus-bootstrap-4/build/css/tempusdominus-bootstrap-4.min.css';
|
||||
|
||||
import { dom, library } from '@fortawesome/fontawesome-svg-core';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
faChevronLeft,
|
||||
faChevronRight,
|
||||
faCalendarCheck,
|
||||
faArrowUp,
|
||||
faArrowDown,
|
||||
faTimes,
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
library.add(
|
||||
faChevronLeft,
|
||||
faChevronRight,
|
||||
faCalendarCheck,
|
||||
faArrowUp,
|
||||
faArrowDown,
|
||||
faTimes,
|
||||
);
|
||||
|
||||
// Sadly needed to also replace <i> within the date picker, since it's not a React component.
|
||||
dom.watch();
|
||||
|
||||
interface TimeInputProps {
|
||||
time: number | null; // Timestamp in milliseconds.
|
||||
range: number; // Range in seconds.
|
||||
placeholder: string;
|
||||
|
||||
onChangeTime: (time: number | null) => void;
|
||||
}
|
||||
|
||||
class TimeInput extends Component<TimeInputProps> {
|
||||
private timeInputRef = React.createRef<HTMLInputElement>();
|
||||
private $time: any | null = null;
|
||||
|
||||
getBaseTime = (): number => {
|
||||
return this.props.time || moment().valueOf();
|
||||
}
|
||||
|
||||
increaseTime = (): void => {
|
||||
const time = this.getBaseTime() + this.props.range*1000/2;
|
||||
this.props.onChangeTime(time);
|
||||
}
|
||||
|
||||
decreaseTime = (): void => {
|
||||
const time = this.getBaseTime() - this.props.range*1000/2;
|
||||
this.props.onChangeTime(time);
|
||||
}
|
||||
|
||||
clearTime = (): void => {
|
||||
this.props.onChangeTime(null);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.$time = $(this.timeInputRef.current!);
|
||||
|
||||
this.$time.datetimepicker({
|
||||
icons: {
|
||||
today: 'fas fa-calendar-check',
|
||||
},
|
||||
buttons: {
|
||||
//showClear: true,
|
||||
showClose: true,
|
||||
showToday: true,
|
||||
},
|
||||
sideBySide: true,
|
||||
format: 'YYYY-MM-DD HH:mm',
|
||||
locale: 'en',
|
||||
timeZone: 'UTC',
|
||||
defaultDate: this.props.time,
|
||||
});
|
||||
|
||||
this.$time.on('change.datetimepicker', (e: any) => {
|
||||
if (e.date) {
|
||||
this.props.onChangeTime(e.date.valueOf());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.$time.datetimepicker('destroy');
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
console.log(this.props);
|
||||
this.$time.datetimepicker('date', this.props.time ? moment(this.props.time) : null);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<InputGroup className="time-input" size="sm">
|
||||
<InputGroupAddon addonType="prepend">
|
||||
<Button title="Decrease time" onClick={this.decreaseTime}><FontAwesomeIcon icon="chevron-left" fixedWidth/></Button>
|
||||
</InputGroupAddon>
|
||||
|
||||
<Input
|
||||
placeholder={this.props.placeholder}
|
||||
innerRef={this.timeInputRef}
|
||||
onFocus={() => this.$time.datetimepicker('show')}
|
||||
onBlur={() => this.$time.datetimepicker('hide')}
|
||||
onKeyDown={(e) => ['Escape', 'Enter'].includes(e.key) && this.$time.datetimepicker('hide')}
|
||||
/>
|
||||
|
||||
{/* CAUTION: While the datetimepicker also has an option to show a 'clear' button,
|
||||
that functionality is broken, so we create an external solution instead. */}
|
||||
{this.props.time &&
|
||||
<InputGroupAddon addonType="append">
|
||||
<Button className="clear-time-btn" title="Clear time" onClick={this.clearTime}><FontAwesomeIcon icon="times" fixedWidth/></Button>
|
||||
</InputGroupAddon>
|
||||
}
|
||||
|
||||
<InputGroupAddon addonType="append">
|
||||
<Button title="Increase time" onClick={this.increaseTime}><FontAwesomeIcon icon="chevron-right" fixedWidth/></Button>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default TimeInput;
|
@ -0,0 +1,4 @@
|
||||
import jquery from 'jquery';
|
||||
|
||||
(window as any).jQuery = jquery;
|
||||
(window as any).moment = require('moment');
|
@ -0,0 +1,7 @@
|
||||
import './globals';
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import App from './App';
|
||||
import 'bootstrap/dist/css/bootstrap.min.css';
|
||||
|
||||
ReactDOM.render(<App />, document.getElementById('root'));
|
@ -0,0 +1 @@
|
||||
/// <reference types="react-scripts" />
|
@ -0,0 +1,38 @@
|
||||
import moment from 'moment-timezone';
|
||||
|
||||
const rangeUnits: {[unit: string]: number} = {
|
||||
'y': 60 * 60 * 24 * 365,
|
||||
'w': 60 * 60 * 24 * 7,
|
||||
'd': 60 * 60 * 24,
|
||||
'h': 60 * 60,
|
||||
'm': 60,
|
||||
's': 1
|
||||
}
|
||||
|
||||
export function parseRange(rangeText: string): number | null {
|
||||
const rangeRE = new RegExp('^([0-9]+)([ywdhms]+)$');
|
||||
const matches = rangeText.match(rangeRE);
|
||||
if (!matches || matches.length !== 3) {
|
||||
return null;
|
||||
}
|
||||
const value = parseInt(matches[1]);
|
||||
const unit = matches[2];
|
||||
return value * rangeUnits[unit];
|
||||
}
|
||||
|
||||
export function formatRange(range: number): string {
|
||||
for (let unit of Object.keys(rangeUnits)) {
|
||||
if (range % rangeUnits[unit] === 0) {
|
||||
return (range / rangeUnits[unit]) + unit;
|
||||
}
|
||||
}
|
||||
return range + 's';
|
||||
}
|
||||
|
||||
export function parseTime(timeText: string): number {
|
||||
return moment.utc(timeText).valueOf();
|
||||
}
|
||||
|
||||
export function formatTime(time: number): string {
|
||||
return moment.utc(time).format('YYYY-MM-DD HH:mm');
|
||||
}
|
@ -0,0 +1,125 @@
|
||||
import { parseRange, parseTime, formatRange, formatTime } from './timeFormat';
|
||||
import { PanelOptions, PanelType, PanelDefaultOptions } from '../Panel';
|
||||
|
||||
export function decodePanelOptionsFromQueryString(query: string): {key: string, options: PanelOptions}[] {
|
||||
if (query === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const params = query.substring(1).split('&');
|
||||
return parseParams(params);
|
||||
}
|
||||
|
||||
const paramFormat = /^g\d+\..+=.+$/;
|
||||
|
||||
interface IncompletePanelOptions {
|
||||
expr?: string;
|
||||
type?: PanelType;
|
||||
range?: number;
|
||||
endTime?: number | null;
|
||||
resolution?: number | null;
|
||||
stacked?: boolean;
|
||||
}
|
||||
|
||||
function parseParams(params: string[]): {key: string, options: PanelOptions}[] {
|
||||
const sortedParams = params.filter((p) => {
|
||||
return paramFormat.test(p);
|
||||
}).sort();
|
||||
|
||||
let panelOpts: {key: string, options: PanelOptions}[] = [];
|
||||
|
||||
let key = 0;
|
||||
let options: IncompletePanelOptions = {};
|
||||
for (const p of sortedParams) {
|
||||
const prefix = 'g' + key + '.';
|
||||
|
||||
if (!p.startsWith(prefix)) {
|
||||
panelOpts.push({
|
||||
key: key.toString(),
|
||||
options: {...PanelDefaultOptions, ...options},
|
||||
});
|
||||
options = {};
|
||||
key++;
|
||||
}
|
||||
|
||||
addParam(options, p.substring(prefix.length));
|
||||
}
|
||||
panelOpts.push({
|
||||
key: key.toString(),
|
||||
options: {...PanelDefaultOptions, ...options},
|
||||
});
|
||||
|
||||
return panelOpts;
|
||||
}
|
||||
|
||||
function addParam(opts: IncompletePanelOptions, param: string): void {
|
||||
let [ opt, val ] = param.split('=');
|
||||
val = decodeURIComponent(val.replace(/\+/g, ' '));
|
||||
|
||||
switch(opt) {
|
||||
case 'expr':
|
||||
opts.expr = val;
|
||||
break;
|
||||
|
||||
case 'tab':
|
||||
if (val === '0') {
|
||||
opts.type = PanelType.Graph;
|
||||
} else {
|
||||
opts.type = PanelType.Table;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'stacked':
|
||||
opts.stacked = val === '1';
|
||||
break;
|
||||
|
||||
case 'range_input':
|
||||
const range = parseRange(val);
|
||||
if (range !== null) {
|
||||
opts.range = range;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'end_input':
|
||||
opts.endTime = parseTime(val);
|
||||
break;
|
||||
|
||||
case 'step_input':
|
||||
const res = parseInt(val);
|
||||
if (res > 0) {
|
||||
opts.resolution = res;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'moment_input':
|
||||
opts.endTime = parseTime(val);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
export function encodePanelOptionsToQueryString(panels: {key: string, options: PanelOptions}[]): string {
|
||||
const queryParams: string[] = [];
|
||||
|
||||
panels.forEach(p => {
|
||||
const prefix = 'g' + p.key + '.';
|
||||
const o = p.options;
|
||||
const panelParams: {[key: string]: string | undefined} = {
|
||||
'expr': o.expr,
|
||||
'tab': o.type === PanelType.Graph ? '0' : '1',
|
||||
'stacked': o.stacked ? '1' : '0',
|
||||
'range_input': formatRange(o.range),
|
||||
'end_input': o.endTime !== null ? formatTime(o.endTime) : undefined,
|
||||
'moment_input': o.endTime !== null ? formatTime(o.endTime) : undefined,
|
||||
'step_input': o.resolution !== null ? o.resolution.toString() : undefined,
|
||||
};
|
||||
|
||||
for (let o in panelParams) {
|
||||
const pp = panelParams[o];
|
||||
if (pp !== undefined) {
|
||||
queryParams.push(prefix + o + '=' + encodeURIComponent(pp));
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return '?' + queryParams.join('&');
|
||||
}
|
@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
File diff suppressed because it is too large
Load Diff
Loading…
Reference in new issue