vmui: predefined panels (#2243)

* feat: add basic components for predefined dashboards

* fix: change display alert

* feat: add autosize and unit for axes

* feat: add component for CircularProgress

* feat: change layout for predefined dashboards

* feat: add override step for predefined panels

* feat: add override step for predefined panels

* feat: change yaxis limits for predefined panels

* fix: rename flag for hide legend

* feat: add formatted panel description

* feat: add README.md for dashboard setup

* feat: validate dashboard settings

* feat: add unit for y-ticks

* fix: correct display error for dashboards

* fix: disable auto refresh after route change

* update package-lock.json

* fix: add basename for BrowserRouter

* fix: add dynamic basename for routing

* update packages

* feat: add a pre-defined dashboard "per-job resource usage"

* feat: display unit in the hover-tooltip

* fix: change routing and home layout

* fix: change axis width calc

* updated packages

* app/vmselect: `make vmui-update`

Co-authored-by: Aliaksandr Valialkin <valyala@victoriametrics.com>
This commit is contained in:
Yury Molodov 2022-03-26 14:03:11 +03:00 committed by GitHub
parent afc2e73948
commit c8d29ed78e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
56 changed files with 2063 additions and 1548 deletions

View file

@ -1,12 +1,14 @@
{
"files": {
"main.css": "./static/css/main.098d452b.css",
"main.js": "./static/js/main.523bd341.js",
"main.css": "./static/css/main.d8362c27.css",
"main.js": "./static/js/main.1c66c512.js",
"static/js/362.1990b49e.chunk.js": "./static/js/362.1990b49e.chunk.js",
"static/js/27.939f971b.chunk.js": "./static/js/27.939f971b.chunk.js",
"static/media/README.md": "./static/media/README.a3933343f0099d3929b4.md",
"index.html": "./index.html"
},
"entrypoints": [
"static/css/main.098d452b.css",
"static/js/main.523bd341.js"
"static/css/main.d8362c27.css",
"static/js/main.1c66c512.js"
]
}

View file

@ -1 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="./favicon.ico"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="VM-UI is a metric explorer for Victoria Metrics"/><link rel="apple-touch-icon" href="./apple-touch-icon.png"/><link rel="icon" type="image/png" sizes="32x32" href="./favicon-32x32.png"><link rel="manifest" href="./manifest.json"/><title>VM UI</title><link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap"/><script defer="defer" src="./static/js/main.523bd341.js"></script><link href="./static/css/main.098d452b.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="./favicon.ico"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="VM-UI is a metric explorer for Victoria Metrics"/><link rel="apple-touch-icon" href="./apple-touch-icon.png"/><link rel="icon" type="image/png" sizes="32x32" href="./favicon-32x32.png"><link rel="manifest" href="./manifest.json"/><title>VM UI</title><link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap"/><script defer="defer" src="./static/js/main.1c66c512.js"></script><link href="./static/css/main.d8362c27.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>

View file

@ -1 +1 @@
body{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen,Ubuntu,Cantarell,Fira Sans,Droid Sans,Helvetica Neue,sans-serif}code{font-family:source-code-pro,Menlo,Monaco,Consolas,Courier New,monospace}.MuiAccordionSummary-content{margin:0!important}.uplot,.uplot *,.uplot :after,.uplot :before{box-sizing:border-box}.uplot{font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;line-height:1.5;width:-webkit-min-content;width:min-content}.u-title{font-size:18px;font-weight:700;text-align:center}.u-wrap{position:relative;-webkit-user-select:none;-ms-user-select:none;user-select:none}.u-over,.u-under{position:absolute}.u-under{overflow:hidden}.uplot canvas{display:block;height:100%;position:relative;width:100%}.u-axis{position:absolute}.u-legend{font-size:14px;margin:auto;text-align:center}.u-inline{display:block}.u-inline *{display:inline-block}.u-inline tr{margin-right:16px}.u-legend th{font-weight:600}.u-legend th>*{display:inline-block;vertical-align:middle}.u-legend .u-marker{background-clip:padding-box!important;height:1em;margin-right:4px;width:1em}.u-inline.u-live th:after{content:":";vertical-align:middle}.u-inline:not(.u-live) .u-value{display:none}.u-series>*{padding:4px}.u-series th{cursor:pointer}.u-legend .u-off>*{opacity:.3}.u-select{background:rgba(0,0,0,.07)}.u-cursor-x,.u-cursor-y,.u-select{pointer-events:none;position:absolute}.u-cursor-x,.u-cursor-y{left:0;top:0;will-change:transform;z-index:100}.u-hz .u-cursor-x,.u-vt .u-cursor-y{border-right:1px dashed #607d8b;height:100%}.u-hz .u-cursor-y,.u-vt .u-cursor-x{border-bottom:1px dashed #607d8b;width:100%}.u-cursor-pt{background-clip:padding-box!important;border:0 solid;border-radius:50%;left:0;pointer-events:none;position:absolute;top:0;will-change:transform;z-index:100}.u-axis.u-off,.u-cursor-pt.u-off,.u-cursor-x.u-off,.u-cursor-y.u-off,.u-select.u-off,.u-tooltip{display:none}.u-tooltip{grid-gap:12px;word-wrap:break-word;background:rgba(57,57,57,.9);border-radius:4px;color:#fff;font-family:monospace;font-size:10px;font-weight:500;line-height:1.4em;max-width:300px;padding:8px;pointer-events:none;position:absolute;z-index:100}.u-tooltip-data{align-items:center;display:flex;flex-wrap:wrap;font-size:11px;line-height:150%}.u-tooltip-data__value{font-weight:700;padding:4px}.u-tooltip__info{grid-gap:4px;display:grid}.u-tooltip__marker{height:12px;margin-right:4px;width:12px}.legendWrapper{grid-gap:20px;cursor:default;display:grid;grid-template-columns:repeat(auto-fit,minmax(400px,1fr));margin-top:20px;position:relative}.legendGroup{margin-bottom:24px}.legendGroupTitle{align-items:center;display:grid;font-size:11px;grid-template-columns:43px auto;padding:10px}.legendGroupQuery{grid-column:1/3;opacity:.6}.legendGroupLine{margin-right:10px}.legendItem{grid-gap:6px;align-items:start;background-color:#fff;cursor:pointer;display:inline-grid;grid-template-columns:auto auto;justify-content:start;padding:7px 50px 7px 10px;transition:.2s ease}.legendItemHide{opacity:.5;text-decoration:line-through}.legendItem:hover{background-color:rgba(0,0,0,.1)}.legendMarker{border-style:solid;border-width:2px;box-sizing:border-box;height:12px;transition:.2s ease;width:12px}.legendLabel{font-size:11px;font-weight:400;line-height:12px}.legendFreeFields{cursor:pointer;padding:3px}.legendFreeFields:hover{text-decoration:underline}.legendFreeFields:not(:last-child):after{content:","}.legendWrapperHotkey{align-items:center;display:flex;font-size:11px}.legendWrapperHotkey p{margin-right:20px}.legendWrapperHotkey code{word-wrap:break-word;background-color:#f2f2f2;border:1px solid #dedede;border-radius:2px;color:#0a0a0a;display:inline;font-size:10px;font-weight:400;max-width:100%;padding:4px 6px}
body{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen,Ubuntu,Cantarell,Fira Sans,Droid Sans,Helvetica Neue,sans-serif}code{font-family:source-code-pro,Menlo,Monaco,Consolas,Courier New,monospace}.MuiAccordionSummary-content{margin:0!important}.uplot,.uplot *,.uplot :after,.uplot :before{box-sizing:border-box}.uplot{font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;line-height:1.5;width:-webkit-min-content;width:min-content}.u-title{font-size:18px;font-weight:700;text-align:center}.u-wrap{position:relative;-webkit-user-select:none;-ms-user-select:none;user-select:none}.u-over,.u-under{position:absolute}.u-under{overflow:hidden}.uplot canvas{display:block;height:100%;position:relative;width:100%}.u-axis{position:absolute}.u-legend{font-size:14px;margin:auto;text-align:center}.u-inline{display:block}.u-inline *{display:inline-block}.u-inline tr{margin-right:16px}.u-legend th{font-weight:600}.u-legend th>*{display:inline-block;vertical-align:middle}.u-legend .u-marker{background-clip:padding-box!important;height:1em;margin-right:4px;width:1em}.u-inline.u-live th:after{content:":";vertical-align:middle}.u-inline:not(.u-live) .u-value{display:none}.u-series>*{padding:4px}.u-series th{cursor:pointer}.u-legend .u-off>*{opacity:.3}.u-select{background:rgba(0,0,0,.07)}.u-cursor-x,.u-cursor-y,.u-select{pointer-events:none;position:absolute}.u-cursor-x,.u-cursor-y{left:0;top:0;will-change:transform;z-index:100}.u-hz .u-cursor-x,.u-vt .u-cursor-y{border-right:1px dashed #607d8b;height:100%}.u-hz .u-cursor-y,.u-vt .u-cursor-x{border-bottom:1px dashed #607d8b;width:100%}.u-cursor-pt{background-clip:padding-box!important;border:0 solid;border-radius:50%;left:0;pointer-events:none;position:absolute;top:0;will-change:transform;z-index:100}.u-axis.u-off,.u-cursor-pt.u-off,.u-cursor-x.u-off,.u-cursor-y.u-off,.u-select.u-off,.u-tooltip{display:none}.u-tooltip{grid-gap:12px;word-wrap:break-word;background:rgba(57,57,57,.9);border-radius:4px;color:#fff;font-family:monospace;font-size:10px;font-weight:500;line-height:1.4em;max-width:300px;padding:8px;pointer-events:none;position:absolute;z-index:100}.u-tooltip-data{align-items:center;display:flex;flex-wrap:wrap;font-size:11px;line-height:150%}.u-tooltip-data__value{font-weight:700;padding:4px}.u-tooltip__info{grid-gap:4px;display:grid}.u-tooltip__marker{height:12px;margin-right:4px;width:12px}.legendWrapper{cursor:default;display:flex;flex-wrap:wrap;margin-top:20px;position:relative}.legendGroup{margin:0 12px 24px 0}.legendGroupTitle{align-items:center;display:grid;font-size:11px;grid-template-columns:43px auto;padding:10px}.legendGroupQuery{grid-column:1/3;opacity:.6}.legendGroupLine{margin-right:10px}.legendItem{grid-gap:6px;align-items:start;background-color:#fff;cursor:pointer;display:grid;grid-template-columns:auto auto;justify-content:start;padding:7px 50px 7px 10px;transition:.2s ease}.legendItemHide{opacity:.5;text-decoration:line-through}.legendItem:hover{background-color:rgba(0,0,0,.1)}.legendMarker{border-style:solid;border-width:2px;box-sizing:border-box;height:12px;transition:.2s ease;width:12px}.legendLabel{font-size:11px;font-weight:400;line-height:12px}.legendFreeFields{cursor:pointer;padding:3px}.legendFreeFields:hover{text-decoration:underline}.legendFreeFields:not(:last-child):after{content:","}.legendWrapperHotkey{align-items:center;display:flex;font-size:11px}.legendWrapperHotkey p{margin-right:20px}.legendWrapperHotkey code{word-wrap:break-word;background-color:#f2f2f2;border:1px solid #dedede;border-radius:2px;color:#0a0a0a;display:inline;font-size:10px;font-weight:400;max-width:100%;padding:4px 6px}.panelDescription ul{line-height:2.2}.panelDescription a{color:#fff}.panelDescription code{background-color:rgba(0,0,0,.3);border-radius:2px;color:#fff;display:inline;font-size:inherit;font-weight:400;max-width:100%;padding:4px 6px}

View file

@ -0,0 +1 @@
"use strict";(self.webpackChunkvmui=self.webpackChunkvmui||[]).push([[362],{8362:function(e,s,u){e.exports=u.p+"static/media/README.a3933343f0099d3929b4.md"}}]);

File diff suppressed because one or more lines are too long

View file

@ -6,7 +6,29 @@
* @license MIT
*/
/** @license MUI v5.4.4
/**
* React Router DOM v6.2.2
*
* Copyright (c) Remix Software Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE.md file in the root directory of this source tree.
*
* @license MIT
*/
/**
* React Router v6.2.2
*
* Copyright (c) Remix Software Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE.md file in the root directory of this source tree.
*
* @license MIT
*/
/** @license MUI v5.5.2
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,76 @@
### Configuration options
<br/>
DashboardSettings:
| Name | Type | Description |
|:----------|:----------------:|---------------------------:|
| rows* | `DashboardRow[]` | Sections containing panels |
| title | `string` | Dashboard title |
<br/>
DashboardRow:
| Name | Type | Description |
|:-----------|:-----------------:|---------------------------:|
| panels* | `PanelSettings[]` | List of panels (charts) |
| title | `string` | Row title |
<br/>
PanelSettings:
| Name | Type | Description |
|:---------------|:----------:|----------------------------------------------------:|
| expr* | `string[]` | Data source queries |
| title | `string` | Panel title |
| description | `string` | Additional information about the panel |
| unit | `string` | Y-axis unit |
| showLegend | `boolean` | If `false`, the legend hide. Default value - `true` |
---
### Example json
```json
{
"title": "Example",
"rows": [
{
"title": "Performance",
"panels": [
{
"title": "Query duration",
"description": "The less time it takes is better.\n* `*` - unsupported query path\n* `/write` - insert into VM\n* `/metrics` - query VM system metrics\n* `/query` - query instant values\n* `/query_range` - query over a range of time\n* `/series` - match a certain label set\n* `/label/{}/values` - query a list of label values (variables mostly)",
"unit": "ms",
"showLegend": false,
"expr": [
"max(vm_request_duration_seconds{quantile=~\"(0.5|0.99)\"}) by (path, quantile) > 0"
]
},
{
"title": "Concurrent flushes on disk",
"description": "Shows how many ongoing insertions (not API /write calls) on disk are taking place, where:\n* `max` - equal to number of CPUs;\n* `current` - current number of goroutines busy with inserting rows into underlying storage.\n\nEvery successful API /write call results into flush on disk. However, these two actions are separated and controlled via different concurrency limiters. The `max` on this panel can't be changed and always equal to number of CPUs. \n\nWhen `current` hits `max` constantly, it means storage is overloaded and requires more CPU.\n\n",
"expr": [
"sum(vm_concurrent_addrows_capacity)",
"sum(vm_concurrent_addrows_current)"
]
}
]
},
{
"title": "Troubleshooting",
"panels": [
{
"title": "Churn rate",
"description": "Shows the rate and total number of new series created over last 24h.\n\nHigh churn rate tightly connected with database performance and may result in unexpected OOM's or slow queries. It is recommended to always keep an eye on this metric to avoid unexpected cardinality \"explosions\".\n\nThe higher churn rate is, the more resources required to handle it. Consider to keep the churn rate as low as possible.\n\nGood references to read:\n* https://www.robustperception.io/cardinality-is-key\n* https://www.robustperception.io/using-tsdb-analyze-to-investigate-churn-and-cardinality",
"expr": [
"sum(rate(vm_new_timeseries_created_total[5m]))",
"sum(increase(vm_new_timeseries_created_total[24h]))"
]
}
]
}
]
}
```

File diff suppressed because it is too large Load diff

View file

@ -17,17 +17,22 @@
"@types/lodash.debounce": "^4.0.6",
"@types/lodash.get": "^4.4.6",
"@types/lodash.throttle": "^4.1.6",
"@types/marked": "^4.0.2",
"@types/node": "^17.0.21",
"@types/qs": "^6.9.7",
"@types/react": "^17.0.41",
"@types/react-dom": "^17.0.14",
"@types/react-measure": "^2.0.8",
"@types/react-router-dom": "^5.3.3",
"@types/webpack-env": "^1.16.3",
"dayjs": "^1.11.0",
"lodash.debounce": "^4.0.8",
"lodash.get": "^4.4.2",
"lodash.throttle": "^4.1.1",
"marked": "^4.0.12",
"preact": "^10.6.6",
"qs": "^6.10.3",
"react-router-dom": "^6.2.1",
"typescript": "~4.6.2",
"uplot": "^1.6.19",
"web-vitals": "^2.1.4"

View file

@ -1,6 +1,6 @@
import React, {FC} from "preact/compat";
import {HashRouter, Route, Routes} from "react-router-dom";
import {SnackbarProvider} from "./contexts/Snackbar";
import HomeLayout from "./components/Home/HomeLayout";
import {StateProvider} from "./state/common/StateContext";
import {AuthStateProvider} from "./state/auth/AuthStateContext";
import {GraphStateProvider} from "./state/graph/GraphStateContext";
@ -9,6 +9,11 @@ import { ThemeProvider, StyledEngineProvider } from "@mui/material/styles";
import CssBaseline from "@mui/material/CssBaseline";
import LocalizationProvider from "@mui/lab/LocalizationProvider";
import DayjsUtils from "@date-io/dayjs";
import router from "./router/index";
import CustomPanel from "./components/CustomPanel/CustomPanel";
import HomeLayout from "./components/Home/HomeLayout";
import DashboardsLayout from "./components/PredefinedPanels/DashboardsLayout";
const App: FC = () => {
@ -22,7 +27,14 @@ const App: FC = () => {
<AuthStateProvider> {/* Auth related info - optionally persisted to Local Storage */}
<GraphStateProvider> {/* Graph settings */}
<SnackbarProvider> {/* Display various snackbars */}
<HomeLayout/>
<HashRouter>
<Routes>
<Route path={"/"} element={<HomeLayout/>}>
<Route path={router.home} element={<CustomPanel/>}/>
<Route path={router.dashboards} element={<DashboardsLayout/>}/>
</Route>
</Routes>
</HashRouter>
</SnackbarProvider>
</GraphStateProvider>
</AuthStateProvider>

View file

@ -3,29 +3,31 @@ import {ChangeEvent} from "react";
import Box from "@mui/material/Box";
import FormControlLabel from "@mui/material/FormControlLabel";
import TextField from "@mui/material/TextField";
import {useGraphDispatch, useGraphState} from "../../../../state/graph/GraphStateContext";
import debounce from "lodash.debounce";
import BasicSwitch from "../../../../theme/switch";
import {AxisRange, YaxisState} from "../../../../state/graph/reducer";
const AxesLimitsConfigurator: FC = () => {
interface AxesLimitsConfiguratorProps {
yaxis: YaxisState,
setYaxisLimits: (limits: AxisRange) => void,
toggleEnableLimits: () => void
}
const AxesLimitsConfigurator: FC<AxesLimitsConfiguratorProps> = ({yaxis, setYaxisLimits, toggleEnableLimits}) => {
const { yaxis } = useGraphState();
const graphDispatch = useGraphDispatch();
const axes = useMemo(() => Object.keys(yaxis.limits.range), [yaxis.limits.range]);
const onChangeYaxisLimits = () => { graphDispatch({type: "TOGGLE_ENABLE_YAXIS_LIMITS"}); };
const onChangeLimit = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>, axis: string, index: number) => {
const newLimits = yaxis.limits.range;
newLimits[axis][index] = +e.target.value;
if (newLimits[axis][0] === newLimits[axis][1] || newLimits[axis][0] > newLimits[axis][1]) return;
graphDispatch({type: "SET_YAXIS_LIMITS", payload: newLimits});
setYaxisLimits(newLimits);
};
const debouncedOnChangeLimit = useCallback(debounce(onChangeLimit, 500), [yaxis.limits.range]);
return <Box display="grid" alignItems="center" gap={2}>
<FormControlLabel
control={<BasicSwitch checked={yaxis.limits.enable} onChange={onChangeYaxisLimits}/>}
control={<BasicSwitch checked={yaxis.limits.enable} onChange={toggleEnableLimits}/>}
label="Fix the limits for y-axis"
/>
<Box display="grid" alignItems="center" gap={2}>

View file

@ -10,6 +10,7 @@ import Typography from "@mui/material/Typography";
import makeStyles from "@mui/styles/makeStyles";
import CloseIcon from "@mui/icons-material/Close";
import ClickAwayListener from "@mui/material/ClickAwayListener";
import {AxisRange, YaxisState} from "../../../../state/graph/reducer";
const useStyles = makeStyles({
popover: {
@ -35,7 +36,13 @@ const useStyles = makeStyles({
const title = "Axes Settings";
const GraphSettings: FC = () => {
interface GraphSettingsProps {
yaxis: YaxisState,
setYaxisLimits: (limits: AxisRange) => void,
toggleEnableLimits: () => void
}
const GraphSettings: FC<GraphSettingsProps> = ({yaxis, setYaxisLimits, toggleEnableLimits}) => {
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
const open = Boolean(anchorEl);
@ -61,7 +68,11 @@ const GraphSettings: FC = () => {
</IconButton>
</div>
<Box className={classes.popoverBody}>
<AxesLimitsConfigurator/>
<AxesLimitsConfigurator
yaxis={yaxis}
setYaxisLimits={setYaxisLimits}
toggleEnableLimits={toggleEnableLimits}
/>
</Box>
</Paper>
</ClickAwayListener>

View file

@ -5,10 +5,14 @@ import {saveToStorage} from "../../../../utils/storage";
import {useAppDispatch, useAppState} from "../../../../state/common/StateContext";
import BasicSwitch from "../../../../theme/switch";
import StepConfigurator from "./StepConfigurator";
import {useGraphDispatch, useGraphState} from "../../../../state/graph/GraphStateContext";
const AdditionalSettings: FC = () => {
const {queryControls: {autocomplete, nocache}} = useAppState();
const {customStep} = useGraphState();
const graphDispatch = useGraphDispatch();
const {queryControls: {autocomplete, nocache}, time: {period: {step}}} = useAppState();
const dispatch = useAppDispatch();
const onChangeAutocomplete = () => {
@ -33,7 +37,13 @@ const AdditionalSettings: FC = () => {
/>
</Box>
<Box ml={2}>
<StepConfigurator/>
<StepConfigurator defaultStep={step} customStepEnable={customStep.enable}
setStep={(value) => {
graphDispatch({type: "SET_CUSTOM_STEP", payload: value});
}}
toggleEnableStep={() => {
graphDispatch({type: "TOGGLE_CUSTOM_STEP"});
}}/>
</Box>
</Box>;
};

View file

@ -0,0 +1,60 @@
import React, {FC, useEffect, useState} from "preact/compat";
import {ChangeEvent} from "react";
import Box from "@mui/material/Box";
import FormControlLabel from "@mui/material/FormControlLabel";
import TextField from "@mui/material/TextField";
import BasicSwitch from "../../../../theme/switch";
interface StepConfiguratorProps {
defaultStep?: number,
customStepEnable: boolean,
setStep: (step: number) => void,
toggleEnableStep: () => void
}
const StepConfigurator: FC<StepConfiguratorProps> = ({
defaultStep, customStepEnable, setStep, toggleEnableStep
}) => {
const [customStep, setCustomStep] = useState(defaultStep);
const [error, setError] = useState(false);
useEffect(() => {
setStep(customStep || 1);
}, [customStep]);
const onChangeStep = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
if (!customStepEnable) return;
const value = +e.target.value;
if (value > 0) {
setCustomStep(value);
setError(false);
} else {
setError(true);
}
};
const onChangeEnableStep = () => {
setError(false);
toggleEnableStep();
};
return <Box display="grid" gridTemplateColumns="auto 120px" alignItems="center">
<FormControlLabel
control={<BasicSwitch checked={customStepEnable} onChange={onChangeEnableStep}/>}
label="Override step value"
/>
<TextField
label="Step value"
type="number"
size="small"
variant="outlined"
value={customStep}
disabled={!customStepEnable}
error={error}
helperText={error ? "step is out of allowed range" : " "}
onChange={onChangeStep}/>
</Box>;
};
export default StepConfigurator;

View file

@ -10,6 +10,7 @@ import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import List from "@mui/material/List";
import ListItem from "@mui/material/ListItem";
import ListItemText from "@mui/material/ListItemText";
import {useLocation} from "react-router-dom";
interface AutoRefreshOption {
seconds: number
@ -36,6 +37,12 @@ export const ExecutionControls: FC = () => {
const dispatch = useAppDispatch();
const {queryControls: {autoRefresh}} = useAppState();
const location = useLocation();
useEffect(() => {
if (autoRefresh) dispatch({type: "TOGGLE_AUTOREFRESH"});
}, [location]);
const [selectedDelay, setSelectedDelay] = useState<AutoRefreshOption>(delayOptions[0]);
const handleChange = (d: AutoRefreshOption) => {

View file

@ -0,0 +1,68 @@
import React, {FC} from "preact/compat";
import Alert from "@mui/material/Alert";
import Box from "@mui/material/Box";
import GraphView from "./Views/GraphView";
import TableView from "./Views/TableView";
import {useAppDispatch, useAppState} from "../../state/common/StateContext";
import QueryConfigurator from "./Configurator/Query/QueryConfigurator";
import {useFetchQuery} from "../../hooks/useFetchQuery";
import JsonView from "./Views/JsonView";
import {DisplayTypeSwitch} from "./Configurator/DisplayTypeSwitch";
import GraphSettings from "./Configurator/Graph/GraphSettings";
import {useGraphDispatch, useGraphState} from "../../state/graph/GraphStateContext";
import {AxisRange} from "../../state/graph/reducer";
import Spinner from "../common/Spinner";
const CustomPanel: FC = () => {
const {displayType, time: {period}, query} = useAppState();
const { customStep, yaxis } = useGraphState();
const dispatch = useAppDispatch();
const graphDispatch = useGraphDispatch();
const setYaxisLimits = (limits: AxisRange) => {
graphDispatch({type: "SET_YAXIS_LIMITS", payload: limits});
};
const toggleEnableLimits = () => {
graphDispatch({type: "TOGGLE_ENABLE_YAXIS_LIMITS"});
};
const setPeriod = ({from, to}: {from: Date, to: Date}) => {
dispatch({type: "SET_PERIOD", payload: {from, to}});
};
const {isLoading, liveData, graphData, error, queryOptions} = useFetchQuery({
visible: true,
customStep
});
return (
<Box p={4} display="grid" gridTemplateRows="auto 1fr" style={{minHeight: "calc(100vh - 64px)"}}>
<QueryConfigurator error={error} queryOptions={queryOptions}/>
<Box height="100%">
{isLoading && <Spinner isLoading={isLoading} height={"500px"}/>}
{<Box height={"100%"} bgcolor={"#fff"}>
<Box display="grid" gridTemplateColumns="1fr auto" alignItems="center" mx={-4} px={4} mb={2}
borderBottom={1} borderColor="divider">
<DisplayTypeSwitch/>
{displayType === "chart" && <GraphSettings
yaxis={yaxis}
setYaxisLimits={setYaxisLimits}
toggleEnableLimits={toggleEnableLimits}
/>}
</Box>
{error && <Alert color="error" severity="error" sx={{whiteSpace: "pre-wrap", mt: 2}}>{error}</Alert>}
{graphData && period && (displayType === "chart") &&
<GraphView data={graphData} period={period} customStep={customStep} query={query} yaxis={yaxis}
setYaxisLimits={setYaxisLimits} setPeriod={setPeriod}/>}
{liveData && (displayType === "code") && <JsonView data={liveData}/>}
{liveData && (displayType === "table") && <TableView data={liveData}/>}
</Box>}
</Box>
</Box>
);
};
export default CustomPanel;

View file

@ -3,14 +3,23 @@ import {MetricResult} from "../../../api/types";
import LineChart from "../../LineChart/LineChart";
import {AlignedData as uPlotData, Series as uPlotSeries} from "uplot";
import Legend from "../../Legend/Legend";
import {useGraphDispatch, useGraphState} from "../../../state/graph/GraphStateContext";
import {getHideSeries, getLegendItem, getSeriesItem} from "../../../utils/uplot/series";
import {getLimitsYAxis, getTimeSeries} from "../../../utils/uplot/axes";
import {LegendItem} from "../../../utils/uplot/types";
import {useAppState} from "../../../state/common/StateContext";
import {TimeParams} from "../../../types";
import {AxisRange, CustomStep, YaxisState} from "../../../state/graph/reducer";
import Alert from "@mui/material/Alert";
export interface GraphViewProps {
data?: MetricResult[];
period: TimeParams;
customStep: CustomStep;
query: string[];
yaxis: YaxisState;
unit?: string;
showLegend?: boolean;
setYaxisLimits: (val: AxisRange) => void
setPeriod: ({from, to}: {from: Date, to: Date}) => void
}
const promValueToNumber = (s: string): number => {
@ -28,10 +37,17 @@ const promValueToNumber = (s: string): number => {
}
};
const GraphView: FC<GraphViewProps> = ({data = []}) => {
const graphDispatch = useGraphDispatch();
const {time: {period}} = useAppState();
const { customStep } = useGraphState();
const GraphView: FC<GraphViewProps> = ({
data = [],
period,
customStep,
query,
yaxis,
unit,
showLegend= true,
setYaxisLimits,
setPeriod
}) => {
const currentStep = useMemo(() => customStep.enable ? customStep.value : period.step || 1, [period.step, customStep]);
const [dataChart, setDataChart] = useState<uPlotData>([[]]);
@ -41,7 +57,7 @@ const GraphView: FC<GraphViewProps> = ({data = []}) => {
const setLimitsYaxis = (values: {[key: string]: number[]}) => {
const limits = getLimitsYAxis(values);
graphDispatch({type: "SET_YAXIS_LIMITS", payload: limits});
setYaxisLimits(limits);
};
const onChangeLegend = (legend: LegendItem, metaKey: boolean) => {
@ -113,11 +129,11 @@ const GraphView: FC<GraphViewProps> = ({data = []}) => {
return <>
{(data.length > 0)
? <div>
<LineChart data={dataChart} series={series} metrics={data}/>
<Legend labels={legend} onChange={onChangeLegend}/>
<LineChart data={dataChart} series={series} metrics={data} period={period} yaxis={yaxis} unit={unit} setPeriod={setPeriod}/>
{showLegend && <Legend labels={legend} query={query} onChange={onChangeLegend}/>}
</div>
: <div style={{textAlign: "center"}}>No data to show</div>}
: <Alert color="warning" severity="warning" sx={{mt: 2}}>No data to show</Alert>}
</>;
};
export default GraphView;
export default GraphView;

View file

@ -10,6 +10,7 @@ import TableRow from "@mui/material/TableRow";
import TableSortLabel from "@mui/material/TableSortLabel";
import makeStyles from "@mui/styles/makeStyles";
import {useSortedCategories} from "../../../hooks/useSortedCategories";
import Alert from "@mui/material/Alert";
export interface GraphViewProps {
data: InstantMetricResult[];
@ -98,7 +99,7 @@ const TableView: FC<GraphViewProps> = ({data}) => {
</TableBody>
</Table>
</TableContainer>
: <div style={{textAlign: "center"}}>No data to show</div>}
: <Alert color="warning" severity="warning" sx={{mt: 2}}>No data to show</Alert>}
</>
);
};

View file

@ -1,15 +1,19 @@
import React, {FC} from "preact/compat";
import React, {FC, useState} from "preact/compat";
import AppBar from "@mui/material/AppBar";
import Box from "@mui/material/Box";
import Link from "@mui/material/Link";
import Toolbar from "@mui/material/Toolbar";
import Typography from "@mui/material/Typography";
import {ExecutionControls} from "../Home/Configurator/Time/ExecutionControls";
import {ExecutionControls} from "../CustomPanel/Configurator/Time/ExecutionControls";
import Logo from "../common/Logo";
import makeStyles from "@mui/styles/makeStyles";
import {setQueryStringWithoutPageReload} from "../../utils/query-string";
import {TimeSelector} from "../Home/Configurator/Time/TimeSelector";
import GlobalSettings from "../Home/Configurator/Settings/GlobalSettings";
import {TimeSelector} from "../CustomPanel/Configurator/Time/TimeSelector";
import GlobalSettings from "../CustomPanel/Configurator/Settings/GlobalSettings";
import {Link as RouterLink, useLocation, useNavigate} from "react-router-dom";
import Tabs from "@mui/material/Tabs";
import Tab from "@mui/material/Tab";
import router from "../../router/index";
const useStyles = makeStyles({
logo: {
@ -32,18 +36,41 @@ const useStyles = makeStyles({
"&:hover": {
opacity: ".8",
}
},
menuLink: {
display: "block",
padding: "16px 8px",
color: "white",
fontSize: "11px",
textDecoration: "none",
cursor: "pointer",
textTransform: "uppercase",
borderRadius: "4px",
transition: ".2s background",
"&:hover": {
boxShadow: "rgba(0, 0, 0, 0.15) 0px 2px 8px"
}
}
});
const Header: FC = () => {
const classes = useStyles();
const {search, pathname} = useLocation();
const navigate = useNavigate();
const [activeMenu, setActiveMenu] = useState(pathname);
const onClickLogo = () => {
navigateHandler(router.home);
setQueryStringWithoutPageReload("");
window.location.reload();
};
const navigateHandler = (pathname: string) => {
navigate({pathname, search: search});
};
return <AppBar position="static" sx={{px: 1, boxShadow: "none"}}>
<Toolbar>
<Box display="grid" alignItems="center" justifyContent="center">
@ -59,6 +86,13 @@ const Header: FC = () => {
create an issue
</Link>
</Box>
<Box sx={{ml: 8}}>
<Tabs value={activeMenu} textColor="inherit" TabIndicatorProps={{style: {background: "white"}}}
onChange={(e, val) => setActiveMenu(val)}>
<Tab label="Custom panel" value={router.home} component={RouterLink} to={`${router.home}${search}`}/>
<Tab label="Dashboards" value={router.dashboards} component={RouterLink} to={`${router.dashboards}${search}`}/>
</Tabs>
</Box>
<Box display="grid" gridTemplateColumns="repeat(3, auto)" gap={1} alignItems="center" ml="auto" mr={0}>
<TimeSelector/>
<ExecutionControls/>
@ -68,4 +102,4 @@ const Header: FC = () => {
</AppBar>;
};
export default Header;
export default Header;

View file

@ -1,53 +0,0 @@
import React, {FC, useCallback, useEffect, useState} from "preact/compat";
import {ChangeEvent} from "react";
import Box from "@mui/material/Box";
import FormControlLabel from "@mui/material/FormControlLabel";
import TextField from "@mui/material/TextField";
import BasicSwitch from "../../../../theme/switch";
import {useGraphDispatch, useGraphState} from "../../../../state/graph/GraphStateContext";
import {useAppState} from "../../../../state/common/StateContext";
import debounce from "lodash.debounce";
const StepConfigurator: FC = () => {
const {customStep} = useGraphState();
const graphDispatch = useGraphDispatch();
const [error, setError] = useState(false);
const {time: {period: {step}}} = useAppState();
const onChangeStep = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const value = +e.target.value;
if (value > 0) {
graphDispatch({type: "SET_CUSTOM_STEP", payload: value});
setError(false);
} else {
setError(true);
}
};
const debouncedOnChangeStep = useCallback(debounce(onChangeStep, 500), [customStep.value]);
const onChangeEnableStep = () => {
setError(false);
graphDispatch({type: "TOGGLE_CUSTOM_STEP"});
};
useEffect(() => {
if (!customStep.enable) graphDispatch({type: "SET_CUSTOM_STEP", payload: step || 1});
}, [step]);
return <Box display="grid" gridTemplateColumns="auto 120px" alignItems="center">
<FormControlLabel
control={<BasicSwitch checked={customStep.enable} onChange={onChangeEnableStep}/>}
label="Override step value"
/>
{customStep.enable &&
<TextField label="Step value" type="number" size="small" variant="outlined"
defaultValue={customStep.value}
error={error}
helperText={error ? "step is out of allowed range" : " "}
onChange={debouncedOnChangeStep}/>
}
</Box>;
};
export default StepConfigurator;

View file

@ -1,62 +1,13 @@
import React, {FC} from "preact/compat";
import Alert from "@mui/material/Alert";
import Box from "@mui/material/Box";
import CircularProgress from "@mui/material/CircularProgress";
import Fade from "@mui/material/Fade";
import GraphView from "./Views/GraphView";
import TableView from "./Views/TableView";
import {useAppState} from "../../state/common/StateContext";
import QueryConfigurator from "./Configurator/Query/QueryConfigurator";
import {useFetchQuery} from "./Configurator/Query/useFetchQuery";
import JsonView from "./Views/JsonView";
import Header from "../Header/Header";
import {DisplayTypeSwitch} from "./Configurator/DisplayTypeSwitch";
import GraphSettings from "./Configurator/Graph/GraphSettings";
import React, {FC} from "preact/compat";
import Box from "@mui/material/Box";
import { Outlet } from "react-router-dom";
const HomeLayout: FC = () => {
const {displayType, time: {period}} = useAppState();
const {isLoading, liveData, graphData, error, queryOptions} = useFetchQuery();
return (
<Box id="homeLayout">
<Header/>
<Box p={4} display="grid" gridTemplateRows="auto 1fr" style={{minHeight: "calc(100vh - 64px)"}}>
<QueryConfigurator error={error} queryOptions={queryOptions}/>
<Box height="100%">
{isLoading && <Fade in={isLoading} style={{
transitionDelay: isLoading ? "300ms" : "0ms",
}}>
<Box alignItems="center" justifyContent="center" flexDirection="column" display="flex"
style={{
width: "100%",
maxWidth: "calc(100vw - 64px)",
position: "absolute",
height: "50%",
background: "linear-gradient(rgba(255,255,255,.7), rgba(255,255,255,.7), rgba(255,255,255,0))"
}}>
<CircularProgress/>
</Box>
</Fade>}
{<Box height={"100%"} bgcolor={"#fff"}>
<Box display="grid" gridTemplateColumns="1fr auto" alignItems="center" mx={-4} px={4} mb={2}
borderBottom={1} borderColor="divider">
<DisplayTypeSwitch/>
{displayType === "chart" && <GraphSettings/>}
</Box>
{error && <Alert color="error" severity="error"
style={{fontSize: "14px", whiteSpace: "pre-wrap", marginTop: "20px"}}>
{error}
</Alert>}
{graphData && period && (displayType === "chart") && <GraphView data={graphData}/>}
{liveData && (displayType === "code") && <JsonView data={liveData}/>}
{liveData && (displayType === "table") && <TableView data={liveData}/>}
</Box>}
</Box>
</Box>
</Box>
);
return <Box id="homeLayout">
<Header/>
<Outlet/>
</Box>;
};
export default HomeLayout;

View file

@ -1,6 +1,5 @@
import React, {FC, useMemo, useState} from "preact/compat";
import {hexToRGB} from "../../utils/color";
import {useAppState} from "../../state/common/StateContext";
import {LegendItem} from "../../utils/uplot/types";
import "./legend.css";
import {getDashLine} from "../../utils/uplot/helpers";
@ -8,12 +7,11 @@ import Tooltip from "@mui/material/Tooltip";
export interface LegendProps {
labels: LegendItem[];
query: string[];
onChange: (item: LegendItem, metaKey: boolean) => void;
}
const Legend: FC<LegendProps> = ({labels, onChange}) => {
const {query} = useAppState();
const Legend: FC<LegendProps> = ({labels, query, onChange}) => {
const [copiedValue, setCopiedValue] = useState("");
const groups = useMemo(() => {

View file

@ -1,14 +1,13 @@
.legendWrapper {
position: relative;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
grid-gap: 20px;
display: flex;
flex-wrap: wrap;
margin-top: 20px;
cursor: default;
}
.legendGroup {
margin-bottom: 24px;
margin: 0 12px 24px 0;
}
.legendGroupTitle {
@ -29,7 +28,7 @@
}
.legendItem {
display: inline-grid;
display: grid;
grid-template-columns: auto auto;
grid-gap: 6px;
align-items: start;

View file

@ -1,7 +1,5 @@
import React, {FC, useCallback, useEffect, useRef, useState} from "preact/compat";
import {useAppDispatch, useAppState} from "../../state/common/StateContext";
import uPlot, {AlignedData as uPlotData, Options as uPlotOptions, Series as uPlotSeries, Range, Scales, Scale} from "uplot";
import {useGraphState} from "../../state/graph/GraphStateContext";
import {defaultOptions} from "../../utils/uplot/helpers";
import {dragChart} from "../../utils/uplot/events";
import {getAxes, getMinMaxBuffer} from "../../utils/uplot/axes";
@ -12,18 +10,22 @@ import throttle from "lodash.throttle";
import "uplot/dist/uPlot.min.css";
import "./tooltip.css";
import useResize from "../../hooks/useResize";
import {TimeParams} from "../../types";
import {YaxisState} from "../../state/graph/reducer";
export interface LineChartProps {
metrics: MetricResult[];
data: uPlotData;
series: uPlotSeries[];
metrics: MetricResult[];
data: uPlotData;
period: TimeParams;
yaxis: YaxisState;
series: uPlotSeries[];
unit?: string;
setPeriod: ({from, to}: {from: Date, to: Date}) => void;
}
enum typeChartUpdate {xRange = "xRange", yRange = "yRange", data = "data"}
const LineChart: FC<LineChartProps> = ({data, series, metrics = []}) => {
const dispatch = useAppDispatch();
const {time: {period}} = useAppState();
const {yaxis} = useGraphState();
const LineChart: FC<LineChartProps> = ({data, series, metrics = [],
period, yaxis, unit, setPeriod}) => {
const uPlotRef = useRef<HTMLDivElement>(null);
const [isPanning, setPanning] = useState(false);
const [xRange, setXRange] = useState({min: period.start, max: period.end});
@ -36,7 +38,7 @@ const LineChart: FC<LineChartProps> = ({data, series, metrics = []}) => {
const tooltipOffset = {left: 0, top: 0};
const setScale = ({min, max}: { min: number, max: number }): void => {
dispatch({type: "SET_PERIOD", payload: {from: new Date(min * 1000), to: new Date(max * 1000)}});
setPeriod({from: new Date(min * 1000), to: new Date(max * 1000)});
};
const throttledSetScale = useCallback(throttle(setScale, 500), []);
const setPlotScale = ({u, min, max}: { u: uPlot, min: number, max: number }) => {
@ -73,7 +75,7 @@ const LineChart: FC<LineChartProps> = ({data, series, metrics = []}) => {
if (tooltipIdx.dataIdx === u.cursor.idx) return;
tooltipIdx.dataIdx = u.cursor.idx || 0;
if (tooltipIdx.seriesIdx !== null && tooltipIdx.dataIdx !== undefined) {
setTooltip({u, tooltipIdx, metrics, series, tooltip, tooltipOffset});
setTooltip({u, tooltipIdx, metrics, series, tooltip, tooltipOffset, unit});
}
};
@ -81,7 +83,7 @@ const LineChart: FC<LineChartProps> = ({data, series, metrics = []}) => {
if (tooltipIdx.seriesIdx === sidx) return;
tooltipIdx.seriesIdx = sidx;
sidx && tooltipIdx.dataIdx !== undefined
? setTooltip({u, tooltipIdx, metrics, series, tooltip, tooltipOffset})
? setTooltip({u, tooltipIdx, metrics, series, tooltip, tooltipOffset, unit})
: tooltip.style.display = "none";
};
const getRangeX = (): Range.MinMax => [xRange.min, xRange.max];
@ -101,7 +103,7 @@ const LineChart: FC<LineChartProps> = ({data, series, metrics = []}) => {
const options: uPlotOptions = {
...defaultOptions,
series,
axes: getAxes(series),
axes: getAxes(series, unit),
scales: {...getScales()},
width: layoutSize.width ? layoutSize.width - 64 : 400,
plugins: [{hooks: {ready: onReadyChart, setCursor, setSeries: seriesFocus}}],
@ -123,7 +125,7 @@ const LineChart: FC<LineChartProps> = ({data, series, metrics = []}) => {
uPlotInst.setData(data);
break;
}
uPlotInst.redraw();
if (!isPanning) uPlotInst.redraw();
};
useEffect(() => setXRange({min: period.start, max: period.end}), [period]);

View file

@ -0,0 +1,53 @@
import React, {FC, useEffect, useMemo, useState} from "preact/compat";
import getDashboardSettings from "./getDashboardSettings";
import {DashboardRow, DashboardSettings} from "../../types";
import Box from "@mui/material/Box";
import Alert from "@mui/material/Alert";
import Tabs from "@mui/material/Tabs";
import Tab from "@mui/material/Tab";
import PredefinedDashboard from "./PredefinedDashboard";
import get from "lodash.get";
const DashboardLayout: FC = () => {
const [dashboards, setDashboards] = useState<DashboardSettings[]>();
const [tab, setTab] = useState(0);
const filename = useMemo(() => get(dashboards, [tab, "filename"], ""), [dashboards, tab]);
const rows = useMemo(() => {
return get(dashboards, [tab, "rows"], []) as DashboardRow[];
}, [dashboards, tab]);
useEffect(() => {
getDashboardSettings().then(d => d.length && setDashboards(d));
}, []);
return <>
{!dashboards && <Alert color="info" severity="info" sx={{m: 4}}>Dashboards not found</Alert>}
{dashboards && <>
<Box sx={{ borderBottom: 1, borderColor: "divider" }}>
<Tabs value={tab} onChange={(e, val) => setTab(val)} aria-label="dashboard-tabs">
{dashboards && dashboards.map((d, i) =>
<Tab key={i} label={d.title || d.filename} id={`tab-${i}`} aria-controls={`tabpanel-${i}`}/>
)}
</Tabs>
</Box>
<Box>
{Array.isArray(rows) && !!rows.length
? rows.map((r,i) =>
<PredefinedDashboard
key={`${tab}_${i}`}
index={i}
filename={filename}
title={r.title}
panels={r.panels}/>)
: <Alert color="error" severity="error" sx={{m: 4}}>
<code>&quot;rows&quot;</code> not found. Check the configuration file <b>{filename}</b>.
</Alert>}
</Box>
</>}
</>;
};
export default DashboardLayout;

View file

@ -0,0 +1,48 @@
import React, {FC} from "preact/compat";
import {DashboardRow} from "../../types";
import Box from "@mui/material/Box";
import Accordion from "@mui/material/Accordion";
import AccordionSummary from "@mui/material/AccordionSummary";
import AccordionDetails from "@mui/material/AccordionDetails";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import Typography from "@mui/material/Typography";
import PredefinedPanels from "./PredefinedPanels";
import Alert from "@mui/material/Alert";
export interface PredefinedDashboardProps extends DashboardRow {
filename: string;
index: number;
}
const PredefinedDashboard: FC<PredefinedDashboardProps> = ({index, title, panels, filename}) => {
return <Accordion defaultExpanded={!index} sx={{boxShadow: "none"}}>
<AccordionSummary
sx={{px: 3, bgcolor: "rgba(227, 242, 253, 0.6)"}}
aria-controls={`panel${index}-content`}
id={`panel${index}-header`}
expandIcon={<ExpandMoreIcon />}
>
<Box display="flex" alignItems="center" width={"100%"}>
{title && <Typography variant="h6" fontWeight="bold" sx={{mr: 2}}>{title}</Typography>}
{panels && <Typography variant="body2" fontStyle="italic">({panels.length} panels)</Typography>}
</Box>
</AccordionSummary>
<AccordionDetails sx={{display: "grid", gridGap: "10px"}}>
{Array.isArray(panels) && !!panels.length
? panels.map((p, i) => <PredefinedPanels key={i}
title={p.title}
description={p.description}
unit={p.unit}
expr={p.expr}
filename={filename}
showLegend={p.showLegend}/>)
: <Alert color="error" severity="error" sx={{m: 4}}>
<code>&quot;panels&quot;</code> not found. Check the configuration file <b>{filename}</b>.
</Alert>
}
</AccordionDetails>
</Accordion>;
};
export default PredefinedDashboard;

View file

@ -0,0 +1,139 @@
import React, {FC, useEffect, useMemo, useRef, useState} from "preact/compat";
import Box from "@mui/material/Box";
import {PanelSettings} from "../../types";
import Tooltip from "@mui/material/Tooltip";
import InfoIcon from "@mui/icons-material/Info";
import Typography from "@mui/material/Typography";
import {useAppDispatch, useAppState} from "../../state/common/StateContext";
import {AxisRange, YaxisState} from "../../state/graph/reducer";
import GraphView from "../CustomPanel/Views/GraphView";
import Alert from "@mui/material/Alert";
import {useFetchQuery} from "../../hooks/useFetchQuery";
import Spinner from "../common/Spinner";
import StepConfigurator from "../CustomPanel/Configurator/Query/StepConfigurator";
import GraphSettings from "../CustomPanel/Configurator/Graph/GraphSettings";
import {CustomStep} from "../../state/graph/reducer";
import {marked} from "marked";
import "./dashboard.css";
export interface PredefinedPanelsProps extends PanelSettings {
filename: string;
}
const PredefinedPanels: FC<PredefinedPanelsProps> = ({
title,
description,
unit,
expr,
showLegend,
filename
}) => {
const {time: {period}} = useAppState();
const dispatch = useAppDispatch();
const containerRef = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(true);
const [customStep, setCustomStep] = useState<CustomStep>({enable: false, value: period.step || 1});
const [yaxis, setYaxis] = useState<YaxisState>({
limits: {
enable: false,
range: {"1": [0, 0]}
}
});
const validExpr = useMemo(() => Array.isArray(expr) && expr.every(q => typeof q === "string"), [expr]);
const {isLoading, graphData, error} = useFetchQuery({
predefinedQuery: validExpr ? expr : [],
display: "chart",
visible,
customStep,
});
const setYaxisLimits = (limits: AxisRange) => {
const tempYaxis = {...yaxis};
tempYaxis.limits.range = limits;
setYaxis(tempYaxis);
};
const toggleEnableLimits = () => {
const tempYaxis = {...yaxis};
tempYaxis.limits.enable = !tempYaxis.limits.enable;
setYaxis(tempYaxis);
};
const setPeriod = ({from, to}: {from: Date, to: Date}) => {
dispatch({type: "SET_PERIOD", payload: {from, to}});
};
useEffect(() => {
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => setVisible(entry.isIntersecting));
}, { threshold: 0.1 });
if (containerRef.current) observer.observe(containerRef.current);
return () => {
if (containerRef.current) observer.unobserve(containerRef.current);
};
}, []);
if (!validExpr) return <Alert color="error" severity="error" sx={{m: 4}}>
<code>&quot;expr&quot;</code> not found. Check the configuration file <b>{filename}</b>.
</Alert>;
return <Box border="1px solid" borderRadius="2px" borderColor="divider" ref={containerRef}>
<Box px={2} py={1} display="grid" gap={1} gridTemplateColumns="18px 1fr auto"
alignItems="center" justifyContent="space-between" borderBottom={"1px solid"} borderColor={"divider"}>
<Tooltip arrow componentsProps={{
tooltip: {
sx: {maxWidth: "100%"}
}
}}
title={<Box sx={{p: 1}}>
{description && <Box mb={2}>
<Typography fontWeight={"500"} sx={{mb: 0.5, textDecoration: "underline"}}>Description:</Typography>
<div className="panelDescription" dangerouslySetInnerHTML={{__html: marked.parse(description)}}/>
</Box>}
<Box>
<Typography fontWeight={"500"} sx={{mb: 0.5, textDecoration: "underline"}}>Queries:</Typography>
<div>
{expr.map((e, i) => <Box key={`${i}_${e}`} mb={0.5}>{e}</Box>)}
</div>
</Box>
</Box>}>
<InfoIcon color="info"/>
</Tooltip>
<Typography variant="subtitle1" gridColumn={2} textAlign={"left"} width={"100%"} fontWeight={500}>
{title || ""}
</Typography>
<Box display={"grid"} gridTemplateColumns={"repeat(2, auto)"} gap={2} alignItems={"center"}>
<StepConfigurator defaultStep={period.step} customStepEnable={customStep.enable}
setStep={(value) => {
setCustomStep({...customStep, value: value});
}}
toggleEnableStep={() => {
setCustomStep({...customStep, enable: !customStep.enable});
}}/>
<GraphSettings yaxis={yaxis} setYaxisLimits={setYaxisLimits} toggleEnableLimits={toggleEnableLimits}/>
</Box>
</Box>
<Box px={2} pb={2}>
{isLoading && <Spinner isLoading={true} height={"500px"}/>}
{error && <Alert color="error" severity="error" sx={{whiteSpace: "pre-wrap", mt: 2}}>{error}</Alert>}
{graphData && <GraphView
data={graphData}
period={period}
customStep={customStep}
query={expr}
yaxis={yaxis}
unit={unit}
showLegend={showLegend}
setYaxisLimits={setYaxisLimits}
setPeriod={setPeriod}/>
}
</Box>
</Box>;
};
export default PredefinedPanels;

View file

@ -0,0 +1,18 @@
.panelDescription ul {
line-height: 2.2;
}
.panelDescription a {
color: #FFFFFF;
}
.panelDescription code {
display: inline;
max-width: 100%;
padding: 4px 6px;
background-color: rgba(0, 0, 0, 0.3);
border-radius: 2px;
font-weight: 400;
font-size: inherit;
color: #FFFFFF;
}

View file

@ -0,0 +1,14 @@
import {DashboardSettings} from "../../types";
const importModule = async (filename: string) => {
const module = await import(`../../dashboards/${filename}`);
module.default.filename = filename;
return module.default as DashboardSettings;
};
export default async () => {
const context = require.context("../../dashboards", true, /\.json$/);
const filenames = context.keys().map(r => r.replace("./", ""));
return await Promise.all(filenames.map(async f => importModule(f)));
};

View file

@ -0,0 +1,30 @@
import React, {FC} from "preact/compat";
import Fade from "@mui/material/Fade";
import Box from "@mui/material/Box";
import CircularProgress from "@mui/material/CircularProgress";
interface SpinnerProps {
isLoading: boolean;
height?: string;
}
const Spinner: FC<SpinnerProps> = ({isLoading, height}) => {
return <Fade in={isLoading} style={{
transitionDelay: isLoading ? "300ms" : "0ms",
}}>
<Box alignItems="center" justifyContent="center" flexDirection="column" display="flex"
style={{
width: "100%",
maxWidth: "calc(100vw - 64px)",
position: "absolute",
height: height ?? "50%",
background: "rgba(255, 255, 255, 0.7)",
pointerEvents: "none",
zIndex: 2,
}}>
<CircularProgress/>
</Box>
</Fade>;
};
export default Spinner;

View file

@ -0,0 +1,76 @@
### Configuration options
<br/>
DashboardSettings:
| Name | Type | Description |
|:----------|:----------------:|---------------------------:|
| rows* | `DashboardRow[]` | Sections containing panels |
| title | `string` | Dashboard title |
<br/>
DashboardRow:
| Name | Type | Description |
|:-----------|:-----------------:|---------------------------:|
| panels* | `PanelSettings[]` | List of panels (charts) |
| title | `string` | Row title |
<br/>
PanelSettings:
| Name | Type | Description |
|:---------------|:----------:|----------------------------------------------------:|
| expr* | `string[]` | Data source queries |
| title | `string` | Panel title |
| description | `string` | Additional information about the panel |
| unit | `string` | Y-axis unit |
| showLegend | `boolean` | If `false`, the legend hide. Default value - `true` |
---
### Example json
```json
{
"title": "Example",
"rows": [
{
"title": "Performance",
"panels": [
{
"title": "Query duration",
"description": "The less time it takes is better.\n* `*` - unsupported query path\n* `/write` - insert into VM\n* `/metrics` - query VM system metrics\n* `/query` - query instant values\n* `/query_range` - query over a range of time\n* `/series` - match a certain label set\n* `/label/{}/values` - query a list of label values (variables mostly)",
"unit": "ms",
"showLegend": false,
"expr": [
"max(vm_request_duration_seconds{quantile=~\"(0.5|0.99)\"}) by (path, quantile) > 0"
]
},
{
"title": "Concurrent flushes on disk",
"description": "Shows how many ongoing insertions (not API /write calls) on disk are taking place, where:\n* `max` - equal to number of CPUs;\n* `current` - current number of goroutines busy with inserting rows into underlying storage.\n\nEvery successful API /write call results into flush on disk. However, these two actions are separated and controlled via different concurrency limiters. The `max` on this panel can't be changed and always equal to number of CPUs. \n\nWhen `current` hits `max` constantly, it means storage is overloaded and requires more CPU.\n\n",
"expr": [
"sum(vm_concurrent_addrows_capacity)",
"sum(vm_concurrent_addrows_current)"
]
}
]
},
{
"title": "Troubleshooting",
"panels": [
{
"title": "Churn rate",
"description": "Shows the rate and total number of new series created over last 24h.\n\nHigh churn rate tightly connected with database performance and may result in unexpected OOM's or slow queries. It is recommended to always keep an eye on this metric to avoid unexpected cardinality \"explosions\".\n\nThe higher churn rate is, the more resources required to handle it. Consider to keep the churn rate as low as possible.\n\nGood references to read:\n* https://www.robustperception.io/cardinality-is-key\n* https://www.robustperception.io/using-tsdb-analyze-to-investigate-churn-and-cardinality",
"expr": [
"sum(rate(vm_new_timeseries_created_total[5m]))",
"sum(increase(vm_new_timeseries_created_total[24h]))"
]
}
]
}
]
}
```

View file

@ -0,0 +1,25 @@
{
"title": "per-job resource usage",
"rows": [
{
"panels": [
{
"title": "Per-job CPU usage",
"expr": ["sum(rate(process_cpu_seconds_total)) by (job)"]
},
{
"title": "Per-job RSS usage",
"expr": ["sum(process_resident_memory_bytes) by (job)"]
},
{
"title": "Per-job disk read",
"expr": ["sum(rate(process_io_storage_read_bytes_total)) by (job)"]
},{
"title": "Per-job disk write",
"expr": ["sum(rate(process_io_storage_written_bytes_total)) by (job)"]
}
]
}
]
}

View file

@ -1,18 +1,25 @@
import {useEffect, useMemo, useCallback, useState} from "preact/compat";
import {getQueryOptions, getQueryRangeUrl, getQueryUrl} from "../../../../api/query-range";
import {useAppState} from "../../../../state/common/StateContext";
import {InstantMetricResult, MetricBase, MetricResult} from "../../../../api/types";
import {isValidHttpUrl} from "../../../../utils/url";
import {ErrorTypes} from "../../../../types";
import {useGraphState} from "../../../../state/graph/GraphStateContext";
import {getAppModeEnable, getAppModeParams} from "../../../../utils/app-mode";
import {getQueryOptions, getQueryRangeUrl, getQueryUrl} from "../api/query-range";
import {useAppState} from "../state/common/StateContext";
import {InstantMetricResult, MetricBase, MetricResult} from "../api/types";
import {isValidHttpUrl} from "../utils/url";
import {ErrorTypes} from "../types";
import {getAppModeEnable, getAppModeParams} from "../utils/app-mode";
import throttle from "lodash.throttle";
import {DisplayType} from "../DisplayTypeSwitch";
import {DisplayType} from "../components/CustomPanel/Configurator/DisplayTypeSwitch";
import {CustomStep} from "../state/graph/reducer";
interface FetchQueryParams {
predefinedQuery?: string[]
visible: boolean
display?: DisplayType,
customStep: CustomStep,
}
const appModeEnable = getAppModeEnable();
const {serverURL: appServerUrl} = getAppModeParams();
export const useFetchQuery = (): {
export const useFetchQuery = ({predefinedQuery, visible, display, customStep}: FetchQueryParams): {
fetchUrl?: string[],
isLoading: boolean,
graphData?: MetricResult[],
@ -22,8 +29,6 @@ export const useFetchQuery = (): {
} => {
const {query, displayType, serverUrl, time: {period}, queryControls: {nocache}} = useAppState();
const {customStep} = useGraphState();
const [queryOptions, setQueryOptions] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const [graphData, setGraphData] = useState<MetricResult[]>();
@ -67,11 +72,10 @@ export const useFetchQuery = (): {
setError(`${e.name}: ${e.message}`);
}
}
setIsLoading(false);
};
const throttledFetchData = useCallback(throttle(fetchData, 300), []);
const throttledFetchData = useCallback(throttle(fetchData, 1000), []);
const fetchOptions = async () => {
const server = appModeEnable ? appServerUrl : serverUrl;
@ -91,16 +95,19 @@ export const useFetchQuery = (): {
const fetchUrl = useMemo(() => {
const server = appModeEnable ? appServerUrl : serverUrl;
const expr = predefinedQuery ?? query;
const displayChart = (display || displayType) === "chart";
if (!period) return;
if (!server) {
setError(ErrorTypes.emptyServer);
} else if (query.every(q => !q.trim())) {
} else if (expr.every(q => !q.trim())) {
setError(ErrorTypes.validQuery);
} else if (isValidHttpUrl(server)) {
if (customStep.enable) period.step = customStep.value;
return query.filter(q => q.trim()).map(q => displayType === "chart"
? getQueryRangeUrl(server, q, period, nocache)
: getQueryUrl(server, q, period));
const updatedPeriod = {...period};
if (customStep.enable) updatedPeriod.step = customStep.value;
return expr.filter(q => q.trim()).map(q => displayChart
? getQueryRangeUrl(server, q, updatedPeriod, nocache)
: getQueryUrl(server, q, updatedPeriod));
} else {
setError(ErrorTypes.validServer);
}
@ -111,10 +118,10 @@ export const useFetchQuery = (): {
fetchOptions();
}, [serverUrl]);
// TODO: this should depend on query as well, but need to decide when to do the request. Doing it on each query change - looks to be a bad idea. Probably can be done on blur
useEffect(() => {
throttledFetchData(fetchUrl, fetchQueue, displayType);
}, [fetchUrl]);
if (!visible) return;
throttledFetchData(fetchUrl, fetchQueue, (display || displayType));
}, [fetchUrl, visible]);
useEffect(() => {
const fetchPast = fetchQueue.slice(0, -1);

View file

@ -0,0 +1,4 @@
export default {
home: "/",
dashboards: "/dashboards"
};

View file

@ -1,5 +1,5 @@
/* eslint max-lines: 0 */
import {DisplayType} from "../../components/Home/Configurator/DisplayTypeSwitch";
import {DisplayType} from "../../components/CustomPanel/Configurator/DisplayTypeSwitch";
import {TimeParams, TimePeriod} from "../../types";
import {
dateFromSeconds,

View file

@ -99,6 +99,7 @@ const THEME = createTheme({
MuiAlert: {
styleOverrides: {
root: {
fontSize: "14px",
boxShadow: "rgba(0, 0, 0, 0.08) 0px 4px 12px"
}
}

View file

@ -33,4 +33,23 @@ export enum ErrorTypes {
emptyServer = "Please enter Server URL",
validServer = "Please provide a valid Server URL",
validQuery = "Please enter a valid Query and execute it"
}
export interface PanelSettings {
title?: string;
description?: string;
unit?: string;
expr: string[];
showLegend?: boolean;
}
export interface DashboardRow {
title?: string;
panels: PanelSettings[];
}
export interface DashboardSettings {
title?: string;
filename: string;
rows: DashboardRow[];
}

View file

@ -31,7 +31,7 @@ const stateToUrlParams = {
export const setQueryStringWithoutPageReload = (qsValue: string): void => {
const w = window;
if (w) {
const newurl = `${w.location.protocol}//${w.location.host}${w.location.pathname}?${qsValue}`;
const newurl = `${w.location.protocol}//${w.location.host}${w.location.pathname}?${qsValue}${w.location.hash}`;
w.history.pushState({ path: newurl }, "", newurl);
}
};

View file

@ -1,12 +1,18 @@
import {Axis, Series} from "uplot";
import uPlot, {Axis, Series} from "uplot";
import {getMaxFromArray, getMinFromArray} from "../math";
import {roundToMilliseconds} from "../time";
import {AxisRange} from "../../state/graph/reducer";
import {formatTicks} from "./helpers";
import {formatTicks, sizeAxis} from "./helpers";
import {TimeParams} from "../../types";
export const getAxes = (series: Series[]): Axis[] => Array.from(new Set(series.map(s => s.scale))).map(a => {
const axis = {scale: a, show: true, font: "10px Arial", values: formatTicks};
export const getAxes = (series: Series[], unit?: string): Axis[] => Array.from(new Set(series.map(s => s.scale))).map(a => {
const axis = {
scale: a,
show: true,
size: sizeAxis,
font: "10px Arial",
values: (u: uPlot, ticks: number[]) => formatTicks(u, ticks, unit)
};
if (!a) return {space: 80};
if (!(Number(a) % 2)) return {...axis, side: 1};
return axis;

View file

@ -1,4 +1,4 @@
import uPlot from "uplot";
import uPlot, {Axis} from "uplot";
import {getColorFromString} from "../color";
export const defaultOptions = {
@ -28,16 +28,40 @@ export const defaultOptions = {
},
};
export const formatTicks = (u: uPlot, ticks: number[]): string[] => {
export const formatTicks = (u: uPlot, ticks: number[], unit = ""): string[] => {
return ticks.map(v => {
const n = Math.abs(v);
if (n > 1e-3 && n < 1e4) {
return v.toString();
}
return v.toExponential(1);
return `${n > 1e-3 && n < 1e4 ? v.toString() : v.toExponential(1)} ${unit}`;
});
};
interface AxisExtend extends Axis {
_size?: number;
}
const getTextWidth = (val: string, font: string): number => {
const span = document.createElement("span");
span.innerText = val;
span.style.cssText = `position: absolute; z-index: -1; pointer-events: none; opacity: 0; font: ${font}`;
document.body.appendChild(span);
const width = span.offsetWidth;
span.remove();
return width;
};
export const sizeAxis = (u: uPlot, values: string[], axisIdx: number, cycleNum: number): number => {
const axis = u.axes[axisIdx] as AxisExtend;
if (cycleNum > 1) return axis._size || 60;
let axisSize = 6 + (axis?.ticks?.size || 0) + (axis.gap || 0);
const longestVal = (values ?? []).reduce((acc, val) => val.length > acc.length ? val : acc, "");
if (longestVal != "") axisSize += getTextWidth(longestVal, u.ctx.font);
return Math.ceil(axisSize);
};
export const getColorLine = (scale: number, label: string): string => getColorFromString(`${scale}${label}`);
export const getDashLine = (group: number): number[] => group <= 1 ? [] : [group*4, group*1.2];

View file

@ -2,7 +2,7 @@ import dayjs from "dayjs";
import {SetupTooltip} from "./types";
import {getColorLine} from "./helpers";
export const setTooltip = ({u, tooltipIdx, metrics, series, tooltip, tooltipOffset}: SetupTooltip): void => {
export const setTooltip = ({u, tooltipIdx, metrics, series, tooltip, tooltipOffset, unit = ""}: SetupTooltip): void => {
const {seriesIdx, dataIdx} = tooltipIdx;
if (seriesIdx === null || dataIdx === undefined) return;
const dataSeries = u.data[seriesIdx][dataIdx];
@ -25,7 +25,7 @@ export const setTooltip = ({u, tooltipIdx, metrics, series, tooltip, tooltipOffs
const marker = `<div class="u-tooltip__marker" style="background: ${color}"></div>`;
tooltip.innerHTML = `<div>${date}</div>
<div class="u-tooltip-data">
${marker}${metric.__name__ || ""}: <b class="u-tooltip-data__value">${dataSeries}</b>
${marker}${metric.__name__ || ""}: <b class="u-tooltip-data__value">${dataSeries}</b> ${unit}
</div>
<div class="u-tooltip__info">${info}</div>`;
};

View file

@ -6,6 +6,7 @@ export interface SetupTooltip {
metrics: MetricResult[],
series: Series[],
tooltip: HTMLDivElement,
unit?: string,
tooltipOffset: {
left: number,
top: number

View file

@ -15,6 +15,7 @@ The following tip changes can be tested by building VictoriaMetrics components f
## tip
* FEATURE: [vmui](https://docs.victoriametrics.com/#vmui): add pre-defined dasbhoards for per-job CPU usage, memory usage and disk IO usage. See [this pull request](https://github.com/VictoriaMetrics/VictoriaMetrics/pull/2243) for details.
* FEATURE: add the following command-line flags, which can be used for fine-grained limiting of CPU and memory usage during various API calls:
* `-search.maxFederateSeries` for limiting the number of time series, which can be returned from [/federate](https://docs.victoriametrics.com/#federation).