mirror of https://github.com/prometheus/prometheus
Browse Source
* 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
Julius Volz
5 years ago
committed by
GitHub
40 changed files with 12807 additions and 1076 deletions
@ -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,68 @@
|
||||
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). |
||||
|
||||
## Available Scripts |
||||
|
||||
In the project directory, you can run: |
||||
|
||||
### `npm start` |
||||
|
||||
Runs the app in the development mode.<br> |
||||
Open [http://localhost:3000](http://localhost:3000) to view it in the browser. |
||||
|
||||
The page will reload if you make edits.<br> |
||||
You will also see any lint errors in the console. |
||||
|
||||
### `npm test` |
||||
|
||||
Launches the test runner in the interactive watch mode.<br> |
||||
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. |
||||
|
||||
### `npm run build` |
||||
|
||||
Builds the app for production to the `build` folder.<br> |
||||
It correctly bundles React in production mode and optimizes the build for the best performance. |
||||
|
||||
The build is minified and the filenames include the hashes.<br> |
||||
Your app is ready to be deployed! |
||||
|
||||
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. |
||||
|
||||
### `npm run eject` |
||||
|
||||
**Note: this is a one-way operation. Once you `eject`, you can’t go back!** |
||||
|
||||
If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. |
||||
|
||||
Instead, it will copy all the configuration files and the transitive dependencies (Webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. |
||||
|
||||
You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. |
||||
|
||||
## Learn More |
||||
|
||||
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). |
||||
|
||||
To learn React, check out the [React documentation](https://reactjs.org/). |
||||
|
||||
### Code Splitting |
||||
|
||||
This section has moved here: https://facebook.github.io/create-react-app/docs/code-splitting |
||||
|
||||
### Analyzing the Bundle Size |
||||
|
||||
This section has moved here: https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size |
||||
|
||||
### Making a Progressive Web App |
||||
|
||||
This section has moved here: https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app |
||||
|
||||
### Advanced Configuration |
||||
|
||||
This section has moved here: https://facebook.github.io/create-react-app/docs/advanced-configuration |
||||
|
||||
### Deployment |
||||
|
||||
This section has moved here: https://facebook.github.io/create-react-app/docs/deployment |
||||
|
||||
### `npm run build` fails to minify |
||||
|
||||
This section has moved here: https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify |
@ -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" |
||||
] |
||||
} |
Loading…
Reference in new issue