mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Merge branch 'master' into 8px-system-margins2
This commit is contained in:
commit
c2f5c12e5a
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
@ -20,7 +20,7 @@ Fixes #
|
||||
|
||||
**Release note**:
|
||||
<!--
|
||||
If this is a user facing change and should be mentioned in relase note add it below. If no, just write "NONE" below.
|
||||
If this is a user facing change and should be mentioned in release note add it below. If no, just write "NONE" below.
|
||||
-->
|
||||
```release-note
|
||||
|
||||
|
@ -5,4 +5,5 @@ pkg/
|
||||
node_modules
|
||||
public/vendor/
|
||||
vendor/
|
||||
data/
|
||||
|
||||
|
@ -34,10 +34,10 @@ To setup a local development environment we recommend reading [Building Grafana
|
||||
### Pull requests with new features
|
||||
Commits should be as small as possible, while ensuring that each commit is correct independently (i.e., each commit should compile and pass tests).
|
||||
|
||||
Make sure to include `closes #<issue>` or `fixes #<issue>` in the pull request description.
|
||||
Make sure to include `Closes #<issue number>` or `Fixes #<issue number>` in the pull request description.
|
||||
|
||||
### Pull requests with bug fixes
|
||||
Please make all changes in one commit if possible. Include `closes #12345` in bottom of the commit message.
|
||||
Please make all changes in one commit if possible. Include `Closes #<issue number>` in bottom of the commit message.
|
||||
A commit message for a bug fix should look something like this.
|
||||
|
||||
```
|
||||
@ -48,7 +48,7 @@ provsioners each provisioner overwrite each other.
|
||||
filling up dashboard_versions quite fast if using
|
||||
default settings.
|
||||
|
||||
closes #12864
|
||||
Closes #12864
|
||||
```
|
||||
|
||||
If the pull request needs changes before its merged the new commits should be rebased into one commit before its merged.
|
@ -7,12 +7,6 @@
|
||||
Grafana is an open source, feature rich metrics dashboard and graph editor for
|
||||
Graphite, Elasticsearch, OpenTSDB, Prometheus and InfluxDB.
|
||||
|
||||

|
||||
|
||||
Join us Feb 25-26 in Los Angeles, California for GrafanaCon - a two-day event with talks focused on Grafana and the surrounding open source monitoring ecosystem. Get deep dives into Loki, the Explore workflow and all of the new features of Grafana 6, plus participate in hands on workshops to help you get the most out of your data.
|
||||
|
||||
Time is running out - grab your ticket now! http://grafanacon.org
|
||||
|
||||
<!---
|
||||

|
||||
-->
|
||||
|
@ -231,6 +231,7 @@ verify_email_enabled = false
|
||||
|
||||
# Background text for the user field on the login page
|
||||
login_hint = email or username
|
||||
password_hint = password
|
||||
|
||||
# Default UI theme ("dark" or "light")
|
||||
default_theme = dark
|
||||
|
@ -211,6 +211,7 @@ log_queries =
|
||||
|
||||
# Background text for the user field on the login page
|
||||
;login_hint = email or username
|
||||
;password_hint = password
|
||||
|
||||
# Default UI theme ("dark" or "light")
|
||||
;default_theme = dark
|
||||
|
@ -83,7 +83,11 @@ or a bot integration via Slack Apps. Follow Slack's guide to set up a bot integr
|
||||
|
||||
Setting | Description
|
||||
---------- | -----------
|
||||
Recipient | allows you to override the Slack recipient.
|
||||
Url | Slack incoming webhook url.
|
||||
Username | Set the username for the bot's message.
|
||||
Recipient | Allows you to override the Slack recipient.
|
||||
Icon emoji | Provide an emoji to use as the icon for the bot's message. Ex :smile:
|
||||
Icon URL | Provide a url to an image to use as the icon for the bot's message.
|
||||
Mention | make it possible to include a mention in the Slack notification sent by Grafana. Ex @here or @channel
|
||||
Token | If provided, Grafana will upload the generated image via Slack's file.upload API method, not the external image destination.
|
||||
|
||||
|
@ -217,10 +217,10 @@ Some OAuth2 providers might not support `client_id` and `client_secret` passed v
|
||||
results in `invalid_client` error. To allow Grafana to authenticate via these type of providers, the client identifiers must be
|
||||
send via POST body, which can be enabled via the following settings:
|
||||
|
||||
```bash
|
||||
[auth.generic_oauth]
|
||||
send_client_credentials_via_post = true
|
||||
```
|
||||
```bash
|
||||
[auth.generic_oauth]
|
||||
send_client_credentials_via_post = true
|
||||
```
|
||||
|
||||
<hr>
|
||||
|
||||
|
@ -342,6 +342,14 @@ options are `Admin` and `Editor`. e.g. :
|
||||
Viewers can edit/inspect dashboard settings in the browser. But not save the dashboard.
|
||||
Defaults to `false`.
|
||||
|
||||
### login_hint
|
||||
|
||||
Text used as placeholder text on login page for login/username input.
|
||||
|
||||
### password_hint
|
||||
|
||||
Text used as placeholder text on login page for password input.
|
||||
|
||||
<hr>
|
||||
|
||||
## [auth]
|
||||
|
@ -139,7 +139,7 @@
|
||||
"gui:build": "ts-node --project ./scripts/cli/tsconfig.json ./scripts/cli/index.ts gui:build",
|
||||
"gui:releasePrepare": "ts-node --project ./scripts/cli/tsconfig.json ./scripts/cli/index.ts gui:release",
|
||||
"gui:publish": "cd packages/grafana-ui/dist && npm publish --access public",
|
||||
"gui:release": "ts-node --project ./scripts/cli/tsconfig.json ./scripts/cli/index.ts gui:release -p",
|
||||
"gui:release": "ts-node --project ./scripts/cli/tsconfig.json ./scripts/cli/index.ts gui:release -p --createVersionCommit",
|
||||
"cli": "ts-node --project ./scripts/cli/tsconfig.json ./scripts/cli/index.ts"
|
||||
},
|
||||
"husky": {
|
||||
|
@ -12,6 +12,36 @@ See [package source](https://github.com/grafana/grafana/tree/master/packages/gra
|
||||
|
||||
`npm install @grafana/ui`
|
||||
|
||||
## Development
|
||||
|
||||
For development purposes we suggest using `yarn link` that will create symlink to @grafana/ui lib. To do so navigate to `packages/grafana-ui` and run `yarn link`. Then, navigate to your project and run `yarn link @grafana/ui` to use the linked version of the lib. To unlink follow the same procedure, but use `yarn unlink` instead.
|
||||
|
||||
## Building @grafana/ui
|
||||
To build @grafana/ui run `npm run gui:build` script *from Grafana repository root*. The build will be created in `packages/grafana-ui/dist` directory. Following steps from [Development](#development) you can test built package.
|
||||
|
||||
## Releasing new version
|
||||
To release new version run `npm run gui:release` script *from Grafana repository root*. The script will prepare the distribution package as well as prompt you to bump library version and publish it to the NPM registry.
|
||||
|
||||
### Automatic version bump
|
||||
When running `npm run gui:release` package.json file will be automatically updated. Also, package.json file will be commited and pushed to upstream branch.
|
||||
|
||||
### Manual version bump
|
||||
To use `package.json` defined version run `npm run gui:release --usePackageJsonVersion` *from Grafana repository root*.
|
||||
|
||||
### Preparing release package without publishing to NPM registry
|
||||
For testing purposes there is `npm run gui:releasePrepare` task that prepares distribution package without publishing it to the NPM registry.
|
||||
|
||||
### V1 release process overview
|
||||
1. Package is compiled with TSC. Typings are created in `/dist` directory, and the compiled js lands in `/compiled` dir
|
||||
2. Rollup creates a CommonJS package based on compiled sources, and outputs it to `/dist` directory
|
||||
3. Readme, changelog and index.js files are moved to `/dist` directory
|
||||
4. Package version is bumped in both `@grafana/ui` package dir and in dist directory.
|
||||
5. Version commit is created and pushed to master branch
|
||||
5. Package is published to npm
|
||||
|
||||
|
||||
## Versioning
|
||||
To limit the confusion related to @grafana/ui and Grafana versioning we decided to keep the major version in sync between those two.
|
||||
This means, that first version of @grafana/ui is taged with 6.0.0-alpha.0 to keep version in sync with Grafana 6.0 release.
|
||||
|
||||
|
||||
|
@ -24,6 +24,7 @@
|
||||
"jquery": "^3.2.1",
|
||||
"lodash": "^4.17.10",
|
||||
"moment": "^2.22.2",
|
||||
"papaparse": "^4.6.3",
|
||||
"react": "^16.6.3",
|
||||
"react-color": "^2.17.0",
|
||||
"react-custom-scrollbars": "^4.2.1",
|
||||
@ -46,6 +47,7 @@
|
||||
"@types/jquery": "^1.10.35",
|
||||
"@types/lodash": "^4.14.119",
|
||||
"@types/node": "^10.12.18",
|
||||
"@types/papaparse": "^4.5.9",
|
||||
"@types/react": "^16.7.6",
|
||||
"@types/react-custom-scrollbars": "^4.0.5",
|
||||
"@types/react-test-renderer": "^16.0.3",
|
||||
|
@ -39,7 +39,7 @@ class ColorInput extends React.PureComponent<ColorInputProps, ColorInputState> {
|
||||
this.props.onChange(color);
|
||||
};
|
||||
|
||||
handleChange = (event: React.SyntheticEvent<HTMLInputElement>) => {
|
||||
onChange = (event: React.SyntheticEvent<HTMLInputElement>) => {
|
||||
const newColor = tinycolor(event.currentTarget.value);
|
||||
|
||||
this.setState({
|
||||
@ -51,7 +51,7 @@ class ColorInput extends React.PureComponent<ColorInputProps, ColorInputState> {
|
||||
}
|
||||
};
|
||||
|
||||
handleBlur = () => {
|
||||
onBlur = () => {
|
||||
const newColor = tinycolor(this.state.value);
|
||||
|
||||
if (!newColor.isValid()) {
|
||||
@ -84,7 +84,7 @@ class ColorInput extends React.PureComponent<ColorInputProps, ColorInputState> {
|
||||
flexGrow: 1,
|
||||
}}
|
||||
>
|
||||
<input className="gf-form-input" value={value} onChange={this.handleChange} onBlur={this.handleBlur} />
|
||||
<input className="gf-form-input" value={value} onChange={this.onChange} onBlur={this.onBlur} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -15,7 +15,7 @@ export const colorPickerFactory = <T extends ColorPickerProps>(
|
||||
static displayName = displayName;
|
||||
pickerTriggerRef = createRef<HTMLDivElement>();
|
||||
|
||||
handleColorChange = (color: string) => {
|
||||
onColorChange = (color: string) => {
|
||||
const { onColorChange, onChange } = this.props;
|
||||
const changeHandler = (onColorChange || onChange) as ColorPickerChangeHandler;
|
||||
|
||||
@ -25,7 +25,7 @@ export const colorPickerFactory = <T extends ColorPickerProps>(
|
||||
render() {
|
||||
const popoverElement = React.createElement(popover, {
|
||||
...this.props,
|
||||
onChange: this.handleColorChange,
|
||||
onChange: this.onColorChange,
|
||||
});
|
||||
const { theme, children } = this.props;
|
||||
|
||||
|
@ -60,7 +60,7 @@ export class ColorPickerPopover<T extends CustomPickersDescriptor> extends React
|
||||
changeHandler(getColorFromHexRgbOrName(color, theme.type));
|
||||
};
|
||||
|
||||
handleTabChange = (tab: PickerType | keyof T) => {
|
||||
onTabChange = (tab: PickerType | keyof T) => {
|
||||
return () => this.setState({ activePicker: tab });
|
||||
};
|
||||
|
||||
@ -104,7 +104,7 @@ export class ColorPickerPopover<T extends CustomPickersDescriptor> extends React
|
||||
<>
|
||||
{Object.keys(customPickers).map(key => {
|
||||
return (
|
||||
<div className={this.getTabClassName(key)} onClick={this.handleTabChange(key)} key={key}>
|
||||
<div className={this.getTabClassName(key)} onClick={this.onTabChange(key)} key={key}>
|
||||
{customPickers[key].name}
|
||||
</div>
|
||||
);
|
||||
@ -119,10 +119,10 @@ export class ColorPickerPopover<T extends CustomPickersDescriptor> extends React
|
||||
return (
|
||||
<div className={`ColorPickerPopover ColorPickerPopover--${colorPickerTheme}`}>
|
||||
<div className="ColorPickerPopover__tabs">
|
||||
<div className={this.getTabClassName('palette')} onClick={this.handleTabChange('palette')}>
|
||||
<div className={this.getTabClassName('palette')} onClick={this.onTabChange('palette')}>
|
||||
Colors
|
||||
</div>
|
||||
<div className={this.getTabClassName('spectrum')} onClick={this.handleTabChange('spectrum')}>
|
||||
<div className={this.getTabClassName('spectrum')} onClick={this.onTabChange('spectrum')}>
|
||||
Custom
|
||||
</div>
|
||||
{this.renderCustomPickerTabs()}
|
||||
|
@ -8,7 +8,7 @@ import { renderComponentWithTheme } from '../../utils/storybook/withTheme';
|
||||
import { UseState } from '../../utils/storybook/UseState';
|
||||
|
||||
const BasicGreen = getColorDefinitionByName('green');
|
||||
const BasicBlue = getColorDefinitionByName('blue');
|
||||
const BasicRed = getColorDefinitionByName('red');
|
||||
const LightBlue = getColorDefinitionByName('light-blue');
|
||||
|
||||
const NamedColorsPaletteStories = storiesOf('UI/ColorPicker/Palettes/NamedColorsPalette', module);
|
||||
@ -41,7 +41,7 @@ NamedColorsPaletteStories.add('Named colors swatch - support for named colors',
|
||||
'Selected color',
|
||||
{
|
||||
Green: BasicGreen.variants.dark,
|
||||
Red: BasicBlue.variants.dark,
|
||||
Red: BasicRed.variants.dark,
|
||||
'Light blue': LightBlue.variants.dark,
|
||||
},
|
||||
'red'
|
||||
|
@ -1,9 +1,9 @@
|
||||
import propDeprecationWarning from '../../utils/propDeprecationWarning';
|
||||
import deprecationWarning from '../../utils/deprecationWarning';
|
||||
import { ColorPickerProps } from './ColorPickerPopover';
|
||||
|
||||
export const warnAboutColorPickerPropsDeprecation = (componentName: string, props: ColorPickerProps) => {
|
||||
const { onColorChange } = props;
|
||||
if (onColorChange) {
|
||||
propDeprecationWarning(componentName, 'onColorChange', 'onChange');
|
||||
deprecationWarning(componentName, 'onColorChange', 'onChange');
|
||||
}
|
||||
};
|
||||
|
@ -1,24 +1,17 @@
|
||||
import React, { FunctionComponent } from 'react';
|
||||
import React from 'react';
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { DeleteButton } from './DeleteButton';
|
||||
|
||||
const CenteredStory: FunctionComponent<{}> = ({ children }) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: '100vh ',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
|
||||
storiesOf('UI/DeleteButton', module)
|
||||
.addDecorator(story => <CenteredStory>{story()}</CenteredStory>)
|
||||
.addDecorator(withCenteredStory)
|
||||
.add('default', () => {
|
||||
return <DeleteButton onConfirm={() => {}} />;
|
||||
return (
|
||||
<DeleteButton
|
||||
onConfirm={() => {
|
||||
action('Delete Confirmed')('delete!');
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import TableInputCSV from './TableInputCSV';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { TableData } from '../../types/data';
|
||||
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
|
||||
|
||||
const TableInputStories = storiesOf('UI/Table/Input', module);
|
||||
|
||||
TableInputStories.addDecorator(withCenteredStory);
|
||||
|
||||
TableInputStories.add('default', () => {
|
||||
return (
|
||||
<div style={{ width: '90%', height: '90vh' }}>
|
||||
<TableInputCSV
|
||||
text={'a,b,c\n1,2,3'}
|
||||
onTableParsed={(table: TableData, text: string) => {
|
||||
console.log('Table', table, text);
|
||||
action('Table')(table, text);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
|
||||
import renderer from 'react-test-renderer';
|
||||
import TableInputCSV from './TableInputCSV';
|
||||
import { TableData } from '../../types/data';
|
||||
|
||||
describe('TableInputCSV', () => {
|
||||
it('renders correctly', () => {
|
||||
const tree = renderer
|
||||
.create(
|
||||
<TableInputCSV
|
||||
text={'a,b,c\n1,2,3'}
|
||||
onTableParsed={(table: TableData, text: string) => {
|
||||
// console.log('Table:', table, 'from:', text);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
.toJSON();
|
||||
//expect(tree).toMatchSnapshot();
|
||||
expect(tree).toBeDefined();
|
||||
});
|
||||
});
|
95
packages/grafana-ui/src/components/Table/TableInputCSV.tsx
Normal file
95
packages/grafana-ui/src/components/Table/TableInputCSV.tsx
Normal file
@ -0,0 +1,95 @@
|
||||
import React from 'react';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { parseCSV, TableParseOptions, TableParseDetails } from '../../utils/processTableData';
|
||||
import { TableData } from '../../types/data';
|
||||
import { AutoSizer } from 'react-virtualized';
|
||||
|
||||
interface Props {
|
||||
options?: TableParseOptions;
|
||||
text: string;
|
||||
onTableParsed: (table: TableData, text: string) => void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
text: string;
|
||||
table: TableData;
|
||||
details: TableParseDetails;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expects the container div to have size set and will fill it 100%
|
||||
*/
|
||||
class TableInputCSV extends React.PureComponent<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
// Shoud this happen in onComponentMounted?
|
||||
const { text, options, onTableParsed } = props;
|
||||
const details = {};
|
||||
const table = parseCSV(text, options, details);
|
||||
this.state = {
|
||||
text,
|
||||
table,
|
||||
details,
|
||||
};
|
||||
onTableParsed(table, text);
|
||||
}
|
||||
|
||||
readCSV = debounce(() => {
|
||||
const details = {};
|
||||
const table = parseCSV(this.state.text, this.props.options, details);
|
||||
this.setState({ table, details });
|
||||
}, 150);
|
||||
|
||||
componentDidUpdate(prevProps: Props, prevState: State) {
|
||||
const { text } = this.state;
|
||||
if (text !== prevState.text || this.props.options !== prevProps.options) {
|
||||
this.readCSV();
|
||||
}
|
||||
// If the props text has changed, replace our local version
|
||||
if (this.props.text !== prevProps.text && this.props.text !== text) {
|
||||
this.setState({ text: this.props.text });
|
||||
}
|
||||
|
||||
if (this.state.table !== prevState.table) {
|
||||
this.props.onTableParsed(this.state.table, this.state.text);
|
||||
}
|
||||
}
|
||||
|
||||
onFooterClicked = (event: any) => {
|
||||
console.log('Errors', this.state);
|
||||
const message = this.state.details
|
||||
.errors!.map(err => {
|
||||
return err.message;
|
||||
})
|
||||
.join('\n');
|
||||
alert('CSV Parsing Errors:\n' + message);
|
||||
};
|
||||
|
||||
onTextChange = (event: any) => {
|
||||
this.setState({ text: event.target.value });
|
||||
};
|
||||
|
||||
render() {
|
||||
const { table, details } = this.state;
|
||||
|
||||
const hasErrors = details.errors && details.errors.length > 0;
|
||||
const footerClassNames = hasErrors ? 'gf-table-input-csv-err' : '';
|
||||
|
||||
return (
|
||||
<AutoSizer>
|
||||
{({ height, width }) => (
|
||||
<div className="gf-table-input-csv" style={{ width, height }}>
|
||||
<textarea placeholder="Enter CSV here..." value={this.state.text} onChange={this.onTextChange} />
|
||||
<footer onClick={this.onFooterClicked} className={footerClassNames}>
|
||||
Rows:{table.rows.length}, Columns:{table.columns.length}
|
||||
{hasErrors ? <i className="fa fa-exclamation-triangle" /> : <i className="fa fa-check-circle" />}
|
||||
</footer>
|
||||
</div>
|
||||
)}
|
||||
</AutoSizer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default TableInputCSV;
|
24
packages/grafana-ui/src/components/Table/_TableInputCSV.scss
Normal file
24
packages/grafana-ui/src/components/Table/_TableInputCSV.scss
Normal file
@ -0,0 +1,24 @@
|
||||
.gf-table-input-csv {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.gf-table-input-csv textarea {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.gf-table-input-csv footer {
|
||||
position: absolute;
|
||||
bottom: 15px;
|
||||
right: 15px;
|
||||
border: 1px solid #222;
|
||||
background: #ccc;
|
||||
padding: 1px 4px;
|
||||
font-size: 80%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.gf-table-input-csv footer.gf-table-input-csv-err {
|
||||
background: yellow;
|
||||
}
|
@ -1,6 +1,7 @@
|
||||
@import 'CustomScrollbar/CustomScrollbar';
|
||||
@import 'DeleteButton/DeleteButton';
|
||||
@import 'ThresholdsEditor/ThresholdsEditor';
|
||||
@import 'Table/TableInputCSV';
|
||||
@import 'Tooltip/Tooltip';
|
||||
@import 'Select/Select';
|
||||
@import 'PanelOptionsGroup/PanelOptionsGroup';
|
||||
|
@ -53,7 +53,7 @@ export interface TimeSeriesVMs {
|
||||
length: number;
|
||||
}
|
||||
|
||||
interface Column {
|
||||
export interface Column {
|
||||
text: string;
|
||||
title?: string;
|
||||
type?: string;
|
||||
|
@ -0,0 +1,66 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`processTableData basic processing should generate a header and fix widths 1`] = `
|
||||
Object {
|
||||
"columnMap": Object {},
|
||||
"columns": Array [
|
||||
Object {
|
||||
"text": "Column 1",
|
||||
},
|
||||
Object {
|
||||
"text": "Column 2",
|
||||
},
|
||||
Object {
|
||||
"text": "Column 3",
|
||||
},
|
||||
],
|
||||
"rows": Array [
|
||||
Array [
|
||||
1,
|
||||
null,
|
||||
null,
|
||||
],
|
||||
Array [
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
],
|
||||
Array [
|
||||
5,
|
||||
6,
|
||||
null,
|
||||
],
|
||||
],
|
||||
"type": "table",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`processTableData basic processing should read header and two rows 1`] = `
|
||||
Object {
|
||||
"columnMap": Object {},
|
||||
"columns": Array [
|
||||
Object {
|
||||
"text": "a",
|
||||
},
|
||||
Object {
|
||||
"text": "b",
|
||||
},
|
||||
Object {
|
||||
"text": "c",
|
||||
},
|
||||
],
|
||||
"rows": Array [
|
||||
Array [
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
],
|
||||
Array [
|
||||
4,
|
||||
5,
|
||||
6,
|
||||
],
|
||||
],
|
||||
"type": "table",
|
||||
}
|
||||
`;
|
6
packages/grafana-ui/src/utils/deprecationWarning.ts
Normal file
6
packages/grafana-ui/src/utils/deprecationWarning.ts
Normal file
@ -0,0 +1,6 @@
|
||||
const deprecationWarning = (file: string, oldName: string, newName: string) => {
|
||||
const message = `[Deprecation warning] ${file}: ${oldName} is deprecated. Use ${newName} instead`;
|
||||
console.warn(message);
|
||||
};
|
||||
|
||||
export default deprecationWarning;
|
@ -2,4 +2,6 @@ export * from './processTimeSeries';
|
||||
export * from './valueFormats/valueFormats';
|
||||
export * from './colors';
|
||||
export * from './namedColorsPalette';
|
||||
export * from './string';
|
||||
export * from './deprecationWarning';
|
||||
export { getMappedValue } from './valueMappings';
|
||||
|
20
packages/grafana-ui/src/utils/processTableData.test.ts
Normal file
20
packages/grafana-ui/src/utils/processTableData.test.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { parseCSV } from './processTableData';
|
||||
|
||||
describe('processTableData', () => {
|
||||
describe('basic processing', () => {
|
||||
it('should read header and two rows', () => {
|
||||
const text = 'a,b,c\n1,2,3\n4,5,6';
|
||||
expect(parseCSV(text)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should generate a header and fix widths', () => {
|
||||
const text = '1\n2,3,4\n5,6';
|
||||
const table = parseCSV(text, {
|
||||
headerIsFirstLine: false,
|
||||
});
|
||||
expect(table.rows.length).toBe(3);
|
||||
|
||||
expect(table).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
133
packages/grafana-ui/src/utils/processTableData.ts
Normal file
133
packages/grafana-ui/src/utils/processTableData.ts
Normal file
@ -0,0 +1,133 @@
|
||||
import { TableData, Column } from '../types/index';
|
||||
|
||||
import Papa, { ParseError, ParseMeta } from 'papaparse';
|
||||
|
||||
// Subset of all parse options
|
||||
export interface TableParseOptions {
|
||||
headerIsFirstLine?: boolean; // Not a papa-parse option
|
||||
delimiter?: string; // default: ","
|
||||
newline?: string; // default: "\r\n"
|
||||
quoteChar?: string; // default: '"'
|
||||
encoding?: string; // default: ""
|
||||
comments?: boolean | string; // default: false
|
||||
}
|
||||
|
||||
export interface TableParseDetails {
|
||||
meta?: ParseMeta;
|
||||
errors?: ParseError[];
|
||||
}
|
||||
|
||||
/**
|
||||
* This makes sure the header and all rows have equal length.
|
||||
*
|
||||
* @param table (immutable)
|
||||
* @returns a new table that has equal length rows, or the same
|
||||
* table if no changes were needed
|
||||
*/
|
||||
export function matchRowSizes(table: TableData): TableData {
|
||||
const { rows } = table;
|
||||
let { columns } = table;
|
||||
|
||||
let sameSize = true;
|
||||
let size = columns.length;
|
||||
rows.forEach(row => {
|
||||
if (size !== row.length) {
|
||||
sameSize = false;
|
||||
size = Math.max(size, row.length);
|
||||
}
|
||||
});
|
||||
if (sameSize) {
|
||||
return table;
|
||||
}
|
||||
|
||||
// Pad Columns
|
||||
if (size !== columns.length) {
|
||||
const diff = size - columns.length;
|
||||
columns = [...columns];
|
||||
for (let i = 0; i < diff; i++) {
|
||||
columns.push({
|
||||
text: 'Column ' + (columns.length + 1),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Pad Rows
|
||||
const fixedRows: any[] = [];
|
||||
rows.forEach(row => {
|
||||
const diff = size - row.length;
|
||||
if (diff > 0) {
|
||||
row = [...row];
|
||||
for (let i = 0; i < diff; i++) {
|
||||
row.push(null);
|
||||
}
|
||||
}
|
||||
fixedRows.push(row);
|
||||
});
|
||||
|
||||
return {
|
||||
columns,
|
||||
rows: fixedRows,
|
||||
type: table.type,
|
||||
columnMap: table.columnMap,
|
||||
};
|
||||
}
|
||||
|
||||
function makeColumns(values: any[]): Column[] {
|
||||
return values.map((value, index) => {
|
||||
if (!value) {
|
||||
value = 'Column ' + (index + 1);
|
||||
}
|
||||
return {
|
||||
text: value.toString().trim(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert CSV text into a valid TableData object
|
||||
*
|
||||
* @param text
|
||||
* @param options
|
||||
* @param details, if exists the result will be filled with debugging details
|
||||
*/
|
||||
export function parseCSV(text: string, options?: TableParseOptions, details?: TableParseDetails): TableData {
|
||||
const results = Papa.parse(text, { ...options, dynamicTyping: true, skipEmptyLines: true });
|
||||
const { data, meta, errors } = results;
|
||||
|
||||
// Fill the parse details for debugging
|
||||
if (details) {
|
||||
details.errors = errors;
|
||||
details.meta = meta;
|
||||
}
|
||||
|
||||
if (!data || data.length < 1) {
|
||||
// Show a more reasonable warning on empty input text
|
||||
if (details && !text) {
|
||||
errors.length = 0;
|
||||
errors.push({
|
||||
code: 'empty',
|
||||
message: 'Empty input text',
|
||||
type: 'warning',
|
||||
row: 0,
|
||||
});
|
||||
details.errors = errors;
|
||||
}
|
||||
return {
|
||||
columns: [],
|
||||
rows: [],
|
||||
type: 'table',
|
||||
columnMap: {},
|
||||
};
|
||||
}
|
||||
|
||||
// Assume the first line is the header unless the config says its not
|
||||
const headerIsNotFirstLine = options && options.headerIsFirstLine === false;
|
||||
const header = headerIsNotFirstLine ? [] : results.data.shift();
|
||||
|
||||
return matchRowSizes({
|
||||
columns: makeColumns(header),
|
||||
rows: results.data,
|
||||
type: 'table',
|
||||
columnMap: {},
|
||||
});
|
||||
}
|
@ -1,6 +0,0 @@
|
||||
const propDeprecationWarning = (componentName: string, propName: string, newPropName: string) => {
|
||||
const message = `[Deprecation warning] ${componentName}: ${propName} is deprecated. Use ${newPropName} instead`;
|
||||
console.warn(message);
|
||||
};
|
||||
|
||||
export default propDeprecationWarning;
|
15
packages/grafana-ui/src/utils/string.test.ts
Normal file
15
packages/grafana-ui/src/utils/string.test.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { stringToJsRegex } from '@grafana/ui';
|
||||
|
||||
describe('stringToJsRegex', () => {
|
||||
it('should parse the valid regex value', () => {
|
||||
const output = stringToJsRegex('/validRegexp/');
|
||||
expect(output).toBeInstanceOf(RegExp);
|
||||
});
|
||||
|
||||
it('should throw error on invalid regex value', () => {
|
||||
const input = '/etc/hostname';
|
||||
expect(() => {
|
||||
stringToJsRegex(input);
|
||||
}).toThrow();
|
||||
});
|
||||
});
|
13
packages/grafana-ui/src/utils/string.ts
Normal file
13
packages/grafana-ui/src/utils/string.ts
Normal file
@ -0,0 +1,13 @@
|
||||
export function stringToJsRegex(str: string): RegExp {
|
||||
if (str[0] !== '/') {
|
||||
return new RegExp('^' + str + '$');
|
||||
}
|
||||
|
||||
const match = str.match(new RegExp('^/(.*?)/(g?i?m?y?)$'));
|
||||
|
||||
if (!match) {
|
||||
throw new Error(`'${str}' is not a valid regular expression.`);
|
||||
}
|
||||
|
||||
return new RegExp(match[1], match[2]);
|
||||
}
|
@ -36,6 +36,7 @@ func (hs *HTTPServer) LoginView(c *m.ReqContext) {
|
||||
viewData.Settings["oauth"] = enabledOAuths
|
||||
viewData.Settings["disableUserSignUp"] = !setting.AllowUserSignUp
|
||||
viewData.Settings["loginHint"] = setting.LoginHint
|
||||
viewData.Settings["passwordHint"] = setting.PasswordHint
|
||||
viewData.Settings["disableLoginForm"] = setting.DisableLoginForm
|
||||
|
||||
if loginError, ok := tryGetEncryptedCookie(c, LoginErrorCookieName); ok {
|
||||
|
@ -219,8 +219,18 @@ func (a *ldapAuther) GetGrafanaUserFor(ctx *m.ReqContext, ldapUser *LdapUserInfo
|
||||
}
|
||||
|
||||
func (a *ldapAuther) serverBind() error {
|
||||
bindFn := func() error {
|
||||
return a.conn.Bind(a.server.BindDN, a.server.BindPassword)
|
||||
}
|
||||
|
||||
if a.server.BindPassword == "" {
|
||||
bindFn = func() error {
|
||||
return a.conn.UnauthenticatedBind(a.server.BindDN)
|
||||
}
|
||||
}
|
||||
|
||||
// bind_dn and bind_password to bind
|
||||
if err := a.conn.Bind(a.server.BindDN, a.server.BindPassword); err != nil {
|
||||
if err := bindFn(); err != nil {
|
||||
a.log.Info("LDAP initial bind failed, %v", err)
|
||||
|
||||
if ldapErr, ok := err.(*ldap.Error); ok {
|
||||
|
@ -78,6 +78,69 @@ func TestLdapAuther(t *testing.T) {
|
||||
})
|
||||
})
|
||||
|
||||
Convey("serverBind", t, func() {
|
||||
Convey("Given bind dn and password configured", func() {
|
||||
conn := &mockLdapConn{}
|
||||
var actualUsername, actualPassword string
|
||||
conn.bindProvider = func(username, password string) error {
|
||||
actualUsername = username
|
||||
actualPassword = password
|
||||
return nil
|
||||
}
|
||||
ldapAuther := &ldapAuther{
|
||||
conn: conn,
|
||||
server: &LdapServerConf{
|
||||
BindDN: "o=users,dc=grafana,dc=org",
|
||||
BindPassword: "bindpwd",
|
||||
},
|
||||
}
|
||||
err := ldapAuther.serverBind()
|
||||
So(err, ShouldBeNil)
|
||||
So(actualUsername, ShouldEqual, "o=users,dc=grafana,dc=org")
|
||||
So(actualPassword, ShouldEqual, "bindpwd")
|
||||
})
|
||||
|
||||
Convey("Given bind dn configured", func() {
|
||||
conn := &mockLdapConn{}
|
||||
unauthenticatedBindWasCalled := false
|
||||
var actualUsername string
|
||||
conn.unauthenticatedBindProvider = func(username string) error {
|
||||
unauthenticatedBindWasCalled = true
|
||||
actualUsername = username
|
||||
return nil
|
||||
}
|
||||
ldapAuther := &ldapAuther{
|
||||
conn: conn,
|
||||
server: &LdapServerConf{
|
||||
BindDN: "o=users,dc=grafana,dc=org",
|
||||
},
|
||||
}
|
||||
err := ldapAuther.serverBind()
|
||||
So(err, ShouldBeNil)
|
||||
So(unauthenticatedBindWasCalled, ShouldBeTrue)
|
||||
So(actualUsername, ShouldEqual, "o=users,dc=grafana,dc=org")
|
||||
})
|
||||
|
||||
Convey("Given empty bind dn and password", func() {
|
||||
conn := &mockLdapConn{}
|
||||
unauthenticatedBindWasCalled := false
|
||||
var actualUsername string
|
||||
conn.unauthenticatedBindProvider = func(username string) error {
|
||||
unauthenticatedBindWasCalled = true
|
||||
actualUsername = username
|
||||
return nil
|
||||
}
|
||||
ldapAuther := &ldapAuther{
|
||||
conn: conn,
|
||||
server: &LdapServerConf{},
|
||||
}
|
||||
err := ldapAuther.serverBind()
|
||||
So(err, ShouldBeNil)
|
||||
So(unauthenticatedBindWasCalled, ShouldBeTrue)
|
||||
So(actualUsername, ShouldBeEmpty)
|
||||
})
|
||||
})
|
||||
|
||||
Convey("When translating ldap user to grafana user", t, func() {
|
||||
|
||||
var user1 = &m.User{}
|
||||
|
@ -1,6 +1,10 @@
|
||||
package notifiers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/log"
|
||||
@ -8,19 +12,26 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/alerting"
|
||||
)
|
||||
|
||||
const DefaultDingdingMsgType = "link"
|
||||
const DingdingOptionsTemplate = `
|
||||
<h3 class="page-heading">DingDing settings</h3>
|
||||
<div class="gf-form">
|
||||
<span class="gf-form-label width-10">Url</span>
|
||||
<input type="text" required class="gf-form-input max-width-70" ng-model="ctrl.model.settings.url" placeholder="https://oapi.dingtalk.com/robot/send?access_token=xxxxxxxxx"></input>
|
||||
</div>
|
||||
<div class="gf-form">
|
||||
<span class="gf-form-label width-10">MessageType</span>
|
||||
<select class="gf-form-input max-width-14" ng-model="ctrl.model.settings.msgType" ng-options="s for s in ['link','actionCard']" ng-init="ctrl.model.settings.msgType=ctrl.model.settings.msgType || '` + DefaultDingdingMsgType + `'"></select>
|
||||
</div>
|
||||
`
|
||||
|
||||
func init() {
|
||||
alerting.RegisterNotifier(&alerting.NotifierPlugin{
|
||||
Type: "dingding",
|
||||
Name: "DingDing",
|
||||
Description: "Sends HTTP POST request to DingDing",
|
||||
Factory: NewDingDingNotifier,
|
||||
OptionsTemplate: `
|
||||
<h3 class="page-heading">DingDing settings</h3>
|
||||
<div class="gf-form">
|
||||
<span class="gf-form-label width-10">Url</span>
|
||||
<input type="text" required class="gf-form-input max-width-26" ng-model="ctrl.model.settings.url" placeholder="https://oapi.dingtalk.com/robot/send?access_token=xxxxxxxxx"></input>
|
||||
</div>
|
||||
`,
|
||||
OptionsTemplate: DingdingOptionsTemplate,
|
||||
})
|
||||
|
||||
}
|
||||
@ -31,8 +42,11 @@ func NewDingDingNotifier(model *m.AlertNotification) (alerting.Notifier, error)
|
||||
return nil, alerting.ValidationError{Reason: "Could not find url property in settings"}
|
||||
}
|
||||
|
||||
msgType := model.Settings.Get("msgType").MustString(DefaultDingdingMsgType)
|
||||
|
||||
return &DingDingNotifier{
|
||||
NotifierBase: NewNotifierBase(model),
|
||||
MsgType: msgType,
|
||||
Url: url,
|
||||
log: log.New("alerting.notifier.dingding"),
|
||||
}, nil
|
||||
@ -40,6 +54,7 @@ func NewDingDingNotifier(model *m.AlertNotification) (alerting.Notifier, error)
|
||||
|
||||
type DingDingNotifier struct {
|
||||
NotifierBase
|
||||
MsgType string
|
||||
Url string
|
||||
log log.Logger
|
||||
}
|
||||
@ -52,6 +67,16 @@ func (this *DingDingNotifier) Notify(evalContext *alerting.EvalContext) error {
|
||||
this.log.Error("Failed to get messageUrl", "error", err, "dingding", this.Name)
|
||||
messageUrl = ""
|
||||
}
|
||||
|
||||
q := url.Values{
|
||||
"pc_slide": {"false"},
|
||||
"url": {messageUrl},
|
||||
}
|
||||
|
||||
// Use special link to auto open the message url outside of Dingding
|
||||
// Refer: https://open-doc.dingtalk.com/docs/doc.htm?treeId=385&articleId=104972&docType=1#s9
|
||||
messageUrl = "dingtalk://dingtalkclient/page/link?" + q.Encode()
|
||||
|
||||
this.log.Info("messageUrl:" + messageUrl)
|
||||
|
||||
message := evalContext.Rule.Message
|
||||
@ -61,7 +86,28 @@ func (this *DingDingNotifier) Notify(evalContext *alerting.EvalContext) error {
|
||||
message = title
|
||||
}
|
||||
|
||||
bodyJSON, err := simplejson.NewJson([]byte(`{
|
||||
for i, match := range evalContext.EvalMatches {
|
||||
message += fmt.Sprintf("\\n%2d. %s: %s", i+1, match.Metric, match.Value)
|
||||
}
|
||||
|
||||
var bodyStr string
|
||||
if this.MsgType == "actionCard" {
|
||||
// Embed the pic into the markdown directly because actionCard doesn't have a picUrl field
|
||||
if picUrl != "" {
|
||||
message = "\\n\\n" + message
|
||||
}
|
||||
|
||||
bodyStr = `{
|
||||
"msgtype": "actionCard",
|
||||
"actionCard": {
|
||||
"text": "` + strings.Replace(message, `"`, "'", -1) + `",
|
||||
"title": "` + strings.Replace(title, `"`, "'", -1) + `",
|
||||
"singleTitle": "More",
|
||||
"singleURL": "` + messageUrl + `"
|
||||
}
|
||||
}`
|
||||
} else {
|
||||
bodyStr = `{
|
||||
"msgtype": "link",
|
||||
"link": {
|
||||
"text": "` + message + `",
|
||||
@ -69,7 +115,10 @@ func (this *DingDingNotifier) Notify(evalContext *alerting.EvalContext) error {
|
||||
"picUrl": "` + picUrl + `",
|
||||
"messageUrl": "` + messageUrl + `"
|
||||
}
|
||||
}`))
|
||||
}`
|
||||
}
|
||||
|
||||
bodyJSON, err := simplejson.NewJson([]byte(bodyStr))
|
||||
|
||||
if err != nil {
|
||||
this.log.Error("Failed to create Json data", "error", err, "dingding", this.Name)
|
||||
|
@ -56,7 +56,7 @@ func createTestEvalContext(cmd *NotificationTestCommand) *EvalContext {
|
||||
|
||||
ctx := NewEvalContext(context.Background(), testRule)
|
||||
if cmd.Settings.Get("uploadImage").MustBool(true) {
|
||||
ctx.ImagePublicUrl = "http://grafana.org/assets/img/blog/mixed_styles.png"
|
||||
ctx.ImagePublicUrl = "https://grafana.com/assets/img/blog/mixed_styles.png"
|
||||
}
|
||||
ctx.IsTestRun = true
|
||||
ctx.Firing = true
|
||||
|
@ -174,6 +174,11 @@ func UpdateDataSource(cmd *m.UpdateDataSourceCommand) error {
|
||||
Version: cmd.Version + 1,
|
||||
}
|
||||
|
||||
sess.UseBool("is_default")
|
||||
sess.UseBool("basic_auth")
|
||||
sess.UseBool("with_credentials")
|
||||
sess.UseBool("read_only")
|
||||
|
||||
var updateSession *xorm.Session
|
||||
if cmd.Version != 0 {
|
||||
// the reason we allow cmd.version > db.version is make it possible for people to force
|
||||
@ -185,7 +190,7 @@ func UpdateDataSource(cmd *m.UpdateDataSourceCommand) error {
|
||||
updateSession = sess.Where("id=? and org_id=?", ds.Id, ds.OrgId)
|
||||
}
|
||||
|
||||
affected, err := updateSession.AllCols().Omit("created").Update(ds)
|
||||
affected, err := updateSession.Update(ds)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -44,6 +44,10 @@ func DeleteOldLoginAttempts(cmd *m.DeleteOldLoginAttemptsCommand) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if result == nil || len(result) == 0 || result[0] == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
maxId = toInt64(result[0]["id"])
|
||||
|
||||
if maxId == 0 {
|
||||
|
@ -109,6 +109,7 @@ var (
|
||||
AutoAssignOrgRole string
|
||||
VerifyEmailEnabled bool
|
||||
LoginHint string
|
||||
PasswordHint string
|
||||
DefaultTheme string
|
||||
DisableLoginForm bool
|
||||
DisableSignoutMenu bool
|
||||
@ -656,6 +657,7 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
|
||||
AutoAssignOrgRole = users.Key("auto_assign_org_role").In("Editor", []string{"Editor", "Admin", "Viewer"})
|
||||
VerifyEmailEnabled = users.Key("verify_email_enabled").MustBool(false)
|
||||
LoginHint = users.Key("login_hint").String()
|
||||
PasswordHint = users.Key("password_hint").String()
|
||||
DefaultTheme = users.Key("default_theme").String()
|
||||
ExternalUserMngLinkUrl = users.Key("external_manage_link_url").String()
|
||||
ExternalUserMngLinkName = users.Key("external_manage_link_name").String()
|
||||
|
@ -496,9 +496,6 @@ func parseQuery(model *simplejson.Json) (*CloudWatchQuery, error) {
|
||||
}
|
||||
|
||||
alias := model.Get("alias").MustString()
|
||||
if alias == "" {
|
||||
alias = "{{metric}}_{{stat}}"
|
||||
}
|
||||
|
||||
returnData := model.Get("returnData").MustBool(false)
|
||||
highResolution := model.Get("highResolution").MustBool(false)
|
||||
@ -521,8 +518,12 @@ func parseQuery(model *simplejson.Json) (*CloudWatchQuery, error) {
|
||||
|
||||
func formatAlias(query *CloudWatchQuery, stat string, dimensions map[string]string) string {
|
||||
if len(query.Id) > 0 && len(query.Expression) > 0 {
|
||||
if len(query.Alias) > 0 {
|
||||
return query.Alias
|
||||
} else {
|
||||
return query.Id
|
||||
}
|
||||
}
|
||||
|
||||
data := map[string]string{}
|
||||
data["region"] = query.Region
|
||||
|
@ -279,7 +279,7 @@ func formatDate(t time.Time, pattern string) string {
|
||||
isoYearShort := fmt.Sprintf("%d", isoYear)[2:4]
|
||||
formatted = strings.Replace(formatted, "<stdIsoYear>", fmt.Sprintf("%d", isoYear), -1)
|
||||
formatted = strings.Replace(formatted, "<stdIsoYearShort>", isoYearShort, -1)
|
||||
formatted = strings.Replace(formatted, "<stdWeekOfYear>", fmt.Sprintf("%d", isoWeek), -1)
|
||||
formatted = strings.Replace(formatted, "<stdWeekOfYear>", fmt.Sprintf("%02d", isoWeek), -1)
|
||||
|
||||
formatted = strings.Replace(formatted, "<stdUnix>", fmt.Sprintf("%d", t.Unix()), -1)
|
||||
|
||||
|
@ -76,6 +76,15 @@ func TestIndexPattern(t *testing.T) {
|
||||
So(indices, ShouldHaveLength, 1)
|
||||
So(indices[0], ShouldEqual, "2018-data")
|
||||
})
|
||||
|
||||
Convey("Should return 01 week", func() {
|
||||
from = fmt.Sprintf("%d", time.Date(2018, 1, 15, 17, 50, 0, 0, time.UTC).UnixNano()/int64(time.Millisecond))
|
||||
to = fmt.Sprintf("%d", time.Date(2018, 1, 15, 17, 55, 0, 0, time.UTC).UnixNano()/int64(time.Millisecond))
|
||||
indexPatternScenario(intervalWeekly, "[data-]GGGG.WW", tsdb.NewTimeRange(from, to), func(indices []string) {
|
||||
So(indices, ShouldHaveLength, 1)
|
||||
So(indices[0], ShouldEqual, "data-2018.03")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Convey("Hourly interval", t, func() {
|
||||
|
@ -37,7 +37,7 @@ export const ToggleButton: FC<ToggleButtonProps> = ({
|
||||
tooltip,
|
||||
onChange,
|
||||
}) => {
|
||||
const handleChange = event => {
|
||||
const onClick = event => {
|
||||
event.stopPropagation();
|
||||
if (onChange) {
|
||||
onChange(value);
|
||||
@ -46,7 +46,7 @@ export const ToggleButton: FC<ToggleButtonProps> = ({
|
||||
|
||||
const btnClassName = `btn ${className} ${selected ? 'active' : ''}`;
|
||||
const button = (
|
||||
<button className={btnClassName} onClick={handleChange}>
|
||||
<button className={btnClassName} onClick={onClick}>
|
||||
<span>{children}</span>
|
||||
</button>
|
||||
);
|
||||
|
@ -2,7 +2,6 @@ import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { SideMenu } from './SideMenu';
|
||||
import appEvents from '../../app_events';
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
|
||||
jest.mock('../../app_events', () => ({
|
||||
emit: jest.fn(),
|
||||
@ -26,7 +25,6 @@ jest.mock('app/core/services/context_srv', () => ({
|
||||
isGrafanaAdmin: false,
|
||||
isEditor: false,
|
||||
hasEditPermissionFolders: false,
|
||||
toggleSideMenu: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
@ -54,20 +52,6 @@ describe('Render', () => {
|
||||
});
|
||||
|
||||
describe('Functions', () => {
|
||||
describe('toggle side menu', () => {
|
||||
const wrapper = setup();
|
||||
const instance = wrapper.instance() as SideMenu;
|
||||
instance.toggleSideMenu();
|
||||
|
||||
it('should call contextSrv.toggleSideMenu', () => {
|
||||
expect(contextSrv.toggleSideMenu).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should emit toggle sidemenu event', () => {
|
||||
expect(appEvents.emit).toHaveBeenCalledWith('toggle-sidemenu');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggle side menu on mobile', () => {
|
||||
const wrapper = setup();
|
||||
const instance = wrapper.instance() as SideMenu;
|
||||
|
@ -1,31 +1,21 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import appEvents from '../../app_events';
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
import TopSection from './TopSection';
|
||||
import BottomSection from './BottomSection';
|
||||
import { store } from 'app/store/store';
|
||||
import config from 'app/core/config';
|
||||
|
||||
const homeUrl = config.appSubUrl || '/';
|
||||
|
||||
export class SideMenu extends PureComponent {
|
||||
toggleSideMenu = () => {
|
||||
// ignore if we just made a location change, stops hiding sidemenu on double clicks of back button
|
||||
const timeSinceLocationChanged = new Date().getTime() - store.getState().location.lastUpdated;
|
||||
if (timeSinceLocationChanged < 1000) {
|
||||
return;
|
||||
}
|
||||
|
||||
contextSrv.toggleSideMenu();
|
||||
appEvents.emit('toggle-sidemenu');
|
||||
};
|
||||
|
||||
toggleSideMenuSmallBreakpoint = () => {
|
||||
appEvents.emit('toggle-sidemenu-mobile');
|
||||
};
|
||||
|
||||
render() {
|
||||
return [
|
||||
<div className="sidemenu__logo" onClick={this.toggleSideMenu} key="logo">
|
||||
<a href={homeUrl} className="sidemenu__logo" key="logo">
|
||||
<img src="public/img/grafana_icon.svg" alt="Grafana" />
|
||||
</div>,
|
||||
</a>,
|
||||
<div className="sidemenu__logo_small_breakpoint" onClick={this.toggleSideMenuSmallBreakpoint} key="hamburger">
|
||||
<i className="fa fa-bars" />
|
||||
<span className="sidemenu__close">
|
||||
|
@ -2,16 +2,16 @@
|
||||
|
||||
exports[`Render should render component 1`] = `
|
||||
Array [
|
||||
<div
|
||||
<a
|
||||
className="sidemenu__logo"
|
||||
href="/"
|
||||
key="logo"
|
||||
onClick={[Function]}
|
||||
>
|
||||
<img
|
||||
alt="Grafana"
|
||||
src="public/img/grafana_icon.svg"
|
||||
/>
|
||||
</div>,
|
||||
</a>,
|
||||
<div
|
||||
className="sidemenu__logo_small_breakpoint"
|
||||
key="hamburger"
|
||||
|
@ -34,6 +34,7 @@ export class Settings {
|
||||
oauth: any;
|
||||
disableUserSignUp: boolean;
|
||||
loginHint: any;
|
||||
passwordHint: any;
|
||||
loginError: any;
|
||||
viewersCanEdit: boolean;
|
||||
editorsCanOwn: boolean;
|
||||
|
@ -25,6 +25,7 @@ export class LoginCtrl {
|
||||
$scope.disableLoginForm = config.disableLoginForm;
|
||||
$scope.disableUserSignUp = config.disableUserSignUp;
|
||||
$scope.loginHint = config.loginHint;
|
||||
$scope.passwordHint = config.passwordHint;
|
||||
|
||||
$scope.loginMode = true;
|
||||
$scope.submitBtnText = 'Log in';
|
||||
|
@ -245,12 +245,13 @@ export function dedupLogRows(logs: LogsModel, strategy: LogsDedupStrategy): Logs
|
||||
}
|
||||
|
||||
const dedupedRows = logs.rows.reduce((result: LogRowModel[], row: LogRowModel, index, list) => {
|
||||
const rowCopy = { ...row };
|
||||
const previous = result[result.length - 1];
|
||||
if (index > 0 && isDuplicateRow(row, previous, strategy)) {
|
||||
previous.duplicates++;
|
||||
} else {
|
||||
row.duplicates = 0;
|
||||
result.push(row);
|
||||
rowCopy.duplicates = 0;
|
||||
result.push(rowCopy);
|
||||
}
|
||||
return result;
|
||||
}, []);
|
||||
|
@ -1,7 +1,6 @@
|
||||
import config from 'app/core/config';
|
||||
import _ from 'lodash';
|
||||
import coreModule from 'app/core/core_module';
|
||||
import store from 'app/core/store';
|
||||
|
||||
export class User {
|
||||
isGrafanaAdmin: any;
|
||||
@ -29,13 +28,10 @@ export class ContextSrv {
|
||||
isSignedIn: any;
|
||||
isGrafanaAdmin: any;
|
||||
isEditor: any;
|
||||
sidemenu: any;
|
||||
sidemenuSmallBreakpoint = false;
|
||||
hasEditPermissionInFolders: boolean;
|
||||
|
||||
constructor() {
|
||||
this.sidemenu = store.getBool('grafana.sidemenu', true);
|
||||
|
||||
if (!config.bootData) {
|
||||
config.bootData = { user: {}, settings: {} };
|
||||
}
|
||||
@ -55,11 +51,6 @@ export class ContextSrv {
|
||||
return !!(document.visibilityState === undefined || document.visibilityState === 'visible');
|
||||
}
|
||||
|
||||
toggleSideMenu() {
|
||||
this.sidemenu = !this.sidemenu;
|
||||
store.set('grafana.sidemenu', this.sidemenu);
|
||||
}
|
||||
|
||||
hasAccessToExplore() {
|
||||
return (this.isEditor || config.viewersCanEdit) && config.exploreEnabled;
|
||||
}
|
||||
|
@ -113,6 +113,34 @@ describe('dedupLogRows()', () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('should return to non-deduped state on same log result', () => {
|
||||
const logs = {
|
||||
rows: [
|
||||
{
|
||||
entry: 'INFO 123',
|
||||
},
|
||||
{
|
||||
entry: 'WARN 123',
|
||||
},
|
||||
{
|
||||
entry: 'WARN 123',
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(dedupLogRows(logs as LogsModel, LogsDedupStrategy.exact).rows).toEqual([
|
||||
{
|
||||
duplicates: 0,
|
||||
entry: 'INFO 123',
|
||||
},
|
||||
{
|
||||
duplicates: 1,
|
||||
entry: 'WARN 123',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(dedupLogRows(logs as LogsModel, LogsDedupStrategy.none).rows).toEqual(logs.rows);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateFieldStats()', () => {
|
||||
|
@ -1,7 +1,6 @@
|
||||
import kbn from 'app/core/utils/kbn';
|
||||
import { getFlotTickDecimals } from 'app/core/utils/ticks';
|
||||
import _ from 'lodash';
|
||||
import { getValueFormat } from '@grafana/ui';
|
||||
import { getValueFormat, stringToJsRegex } from '@grafana/ui';
|
||||
|
||||
function matchSeriesOverride(aliasOrRegex, seriesAlias) {
|
||||
if (!aliasOrRegex) {
|
||||
@ -9,7 +8,7 @@ function matchSeriesOverride(aliasOrRegex, seriesAlias) {
|
||||
}
|
||||
|
||||
if (aliasOrRegex[0] === '/') {
|
||||
const regex = kbn.stringToJsRegex(aliasOrRegex);
|
||||
const regex = stringToJsRegex(aliasOrRegex);
|
||||
return seriesAlias.match(regex) != null;
|
||||
}
|
||||
|
||||
|
@ -1,5 +1,6 @@
|
||||
import _ from 'lodash';
|
||||
import { getValueFormat, getValueFormatterIndex, getValueFormats } from '@grafana/ui';
|
||||
import { getValueFormat, getValueFormatterIndex, getValueFormats, stringToJsRegex } from '@grafana/ui';
|
||||
import deprecationWarning from '@grafana/ui/src/utils/deprecationWarning';
|
||||
|
||||
const kbn: any = {};
|
||||
|
||||
@ -228,13 +229,10 @@ kbn.slugifyForUrl = str => {
|
||||
.replace(/ +/g, '-');
|
||||
};
|
||||
|
||||
/** deprecated since 6.1, use grafana/ui */
|
||||
kbn.stringToJsRegex = str => {
|
||||
if (str[0] !== '/') {
|
||||
return new RegExp('^' + str + '$');
|
||||
}
|
||||
|
||||
const match = str.match(new RegExp('^/(.*?)/(g?i?m?y?)$'));
|
||||
return new RegExp(match[1], match[2]);
|
||||
deprecationWarning('kbn.ts', 'kbn.stringToJsRegex()', '@grafana/ui');
|
||||
return stringToJsRegex(str);
|
||||
};
|
||||
|
||||
kbn.toFixed = (value, decimals) => {
|
||||
|
@ -16,18 +16,20 @@ const template = `
|
||||
<form name="ctrl.saveForm" class="modal-content" novalidate>
|
||||
<div class="p-t-2">
|
||||
<div class="gf-form">
|
||||
<label class="gf-form-label width-7">New name</label>
|
||||
<label class="gf-form-label width-8">New name</label>
|
||||
<input type="text" class="gf-form-input" ng-model="ctrl.clone.title" give-focus="true" required>
|
||||
</div>
|
||||
<div class="gf-form">
|
||||
<folder-picker initial-folder-id="ctrl.folderId"
|
||||
on-change="ctrl.onFolderChange($folder)"
|
||||
enter-folder-creation="ctrl.onEnterFolderCreation()"
|
||||
exit-folder-creation="ctrl.onExitFolderCreation()"
|
||||
enable-create-new="true"
|
||||
label-class="width-7"
|
||||
label-class="width-8"
|
||||
dashboard-id="ctrl.clone.id">
|
||||
</folder-picker>
|
||||
<div class="gf-form-inline">
|
||||
<gf-form-switch class="gf-form" label="Copy tags" label-class="width-8" checked="ctrl.copyTags">
|
||||
</gf-form-switch>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -44,6 +46,7 @@ export class SaveDashboardAsModalCtrl {
|
||||
folderId: any;
|
||||
dismiss: () => void;
|
||||
isValidFolderSelection = true;
|
||||
copyTags: boolean;
|
||||
|
||||
/** @ngInject */
|
||||
constructor(private dashboardSrv) {
|
||||
@ -55,6 +58,7 @@ export class SaveDashboardAsModalCtrl {
|
||||
this.clone.editable = true;
|
||||
this.clone.hideControls = false;
|
||||
this.folderId = dashboard.meta.folderId;
|
||||
this.copyTags = false;
|
||||
|
||||
// remove alerts if source dashboard is already persisted
|
||||
// do not want to create alert dupes
|
||||
@ -71,6 +75,10 @@ export class SaveDashboardAsModalCtrl {
|
||||
}
|
||||
|
||||
save() {
|
||||
if (!this.copyTags) {
|
||||
this.clone.tags = [];
|
||||
}
|
||||
|
||||
return this.dashboardSrv.save(this.clone, { folderId: this.folderId }).then(this.dismiss);
|
||||
}
|
||||
|
||||
|
@ -116,7 +116,7 @@ export class DataPanel extends Component<Props, State> {
|
||||
this.setState({ loading: LoadingState.Loading });
|
||||
|
||||
try {
|
||||
const ds = await this.dataSourceSrv.get(datasource);
|
||||
const ds = await this.dataSourceSrv.get(datasource, scopedVars);
|
||||
|
||||
// TODO interpolate variables
|
||||
const minInterval = this.props.minInterval || ds.interval;
|
||||
|
@ -13,8 +13,19 @@ interface Props {
|
||||
export class LogLabels extends PureComponent<Props> {
|
||||
render() {
|
||||
const { getRows, labels, onClickLabel, plain } = this.props;
|
||||
return Object.keys(labels).map(key => (
|
||||
<LogLabel key={key} getRows={getRows} label={key} value={labels[key]} plain={plain} onClickLabel={onClickLabel} />
|
||||
));
|
||||
return (
|
||||
<span className="logs-labels">
|
||||
{Object.keys(labels).map(key => (
|
||||
<LogLabel
|
||||
key={key}
|
||||
getRows={getRows}
|
||||
label={key}
|
||||
value={labels[key]}
|
||||
plain={plain}
|
||||
onClickLabel={onClickLabel}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -150,7 +150,7 @@ export class LogRow extends PureComponent<Props, State> {
|
||||
</div>
|
||||
)}
|
||||
{showLocalTime && (
|
||||
<div className="logs-row__time" title={`${row.timestamp} (${row.timeFromNow})`}>
|
||||
<div className="logs-row__localtime" title={`${row.timestamp} (${row.timeFromNow})`}>
|
||||
{row.timeLocal}
|
||||
</div>
|
||||
)}
|
||||
|
108
public/app/features/explore/state/selectors.test.ts
Normal file
108
public/app/features/explore/state/selectors.test.ts
Normal file
@ -0,0 +1,108 @@
|
||||
import { deduplicatedLogsSelector } from './selectors';
|
||||
import { LogsDedupStrategy } from 'app/core/logs_model';
|
||||
import { ExploreItemState } from 'app/types';
|
||||
|
||||
const state = {
|
||||
logsResult: {
|
||||
rows: [
|
||||
{
|
||||
entry: '2019-03-05T11:00:56Z sntpc sntpc[1]: offset=-0.033938, delay=0.000649',
|
||||
},
|
||||
{
|
||||
entry: '2019-03-05T11:00:26Z sntpc sntpc[1]: offset=-0.033730, delay=0.000581',
|
||||
},
|
||||
{
|
||||
entry: '2019-03-05T10:59:56Z sntpc sntpc[1]: offset=-0.034184, delay=0.001089',
|
||||
},
|
||||
{
|
||||
entry: '2019-03-05T10:59:26Z sntpc sntpc[1]: offset=-0.033972, delay=0.000582',
|
||||
},
|
||||
{
|
||||
entry: '2019-03-05T10:58:56Z sntpc sntpc[1]: offset=-0.033955, delay=0.000606',
|
||||
},
|
||||
{
|
||||
entry: '2019-03-05T10:58:26Z sntpc sntpc[1]: offset=-0.034067, delay=0.000616',
|
||||
},
|
||||
{
|
||||
entry: '2019-03-05T10:57:56Z sntpc sntpc[1]: offset=-0.034155, delay=0.001021',
|
||||
},
|
||||
{
|
||||
entry: '2019-03-05T10:57:26Z sntpc sntpc[1]: offset=-0.035797, delay=0.000883',
|
||||
},
|
||||
{
|
||||
entry: '2019-03-05T10:56:56Z sntpc sntpc[1]: offset=-0.046818, delay=0.000605',
|
||||
},
|
||||
{
|
||||
entry: '2019-03-05T10:56:26Z sntpc sntpc[1]: offset=-0.049200, delay=0.000584',
|
||||
},
|
||||
],
|
||||
},
|
||||
hiddenLogLevels: undefined,
|
||||
dedupStrategy: LogsDedupStrategy.none,
|
||||
};
|
||||
|
||||
describe('Deduplication selector', () => {
|
||||
it('should correctly deduplicate log rows when changing strategy multiple times', () => {
|
||||
// Simulating sequence of UI actions that was causing a problem with deduplication counter being visible when unnecessary.
|
||||
// The sequence was changing dedup strategy: (none -> exact -> numbers -> signature -> none) *2 -> exact. After that the first
|
||||
// row contained information that was deduped, while it shouldn't be.
|
||||
// Problem was caused by mutating the log results entries in redux state. The memoisation hash for deduplicatedLogsSelector
|
||||
// was changing depending on duplicates information from log row state, while should be dependand on log row only.
|
||||
|
||||
let dedups = deduplicatedLogsSelector(state as ExploreItemState);
|
||||
expect(dedups.rows.length).toBe(10);
|
||||
|
||||
deduplicatedLogsSelector({
|
||||
...state,
|
||||
dedupStrategy: LogsDedupStrategy.none,
|
||||
} as ExploreItemState);
|
||||
|
||||
deduplicatedLogsSelector({
|
||||
...state,
|
||||
dedupStrategy: LogsDedupStrategy.exact,
|
||||
} as ExploreItemState);
|
||||
|
||||
deduplicatedLogsSelector({
|
||||
...state,
|
||||
dedupStrategy: LogsDedupStrategy.numbers,
|
||||
} as ExploreItemState);
|
||||
|
||||
deduplicatedLogsSelector({
|
||||
...state,
|
||||
dedupStrategy: LogsDedupStrategy.signature,
|
||||
} as ExploreItemState);
|
||||
|
||||
deduplicatedLogsSelector({
|
||||
...state,
|
||||
dedupStrategy: LogsDedupStrategy.none,
|
||||
} as ExploreItemState);
|
||||
|
||||
deduplicatedLogsSelector({
|
||||
...state,
|
||||
dedupStrategy: LogsDedupStrategy.exact,
|
||||
} as ExploreItemState);
|
||||
|
||||
deduplicatedLogsSelector({
|
||||
...state,
|
||||
dedupStrategy: LogsDedupStrategy.numbers,
|
||||
} as ExploreItemState);
|
||||
|
||||
deduplicatedLogsSelector({
|
||||
...state,
|
||||
dedupStrategy: LogsDedupStrategy.signature,
|
||||
} as ExploreItemState);
|
||||
|
||||
deduplicatedLogsSelector({
|
||||
...state,
|
||||
dedupStrategy: LogsDedupStrategy.none,
|
||||
} as ExploreItemState);
|
||||
|
||||
dedups = deduplicatedLogsSelector({
|
||||
...state,
|
||||
dedupStrategy: LogsDedupStrategy.exact,
|
||||
} as ExploreItemState);
|
||||
|
||||
// Expecting that no row has duplicates now
|
||||
expect(dedups.rows.reduce((acc, row) => acc + row.duplicates, 0)).toBe(0);
|
||||
});
|
||||
});
|
@ -81,7 +81,7 @@ class MetricsPanelCtrl extends PanelCtrl {
|
||||
|
||||
// load datasource service
|
||||
this.datasourceSrv
|
||||
.get(this.panel.datasource)
|
||||
.get(this.panel.datasource, this.panel.scopedVars)
|
||||
.then(this.updateTimeRange.bind(this))
|
||||
.then(this.issueQueries.bind(this))
|
||||
.then(this.handleQueryResult.bind(this))
|
||||
|
@ -7,7 +7,7 @@ import config from 'app/core/config';
|
||||
import { importPluginModule } from './plugin_loader';
|
||||
|
||||
// Types
|
||||
import { DataSourceApi, DataSourceSelectItem } from '@grafana/ui/src/types';
|
||||
import { DataSourceApi, DataSourceSelectItem, ScopedVars } from '@grafana/ui/src/types';
|
||||
|
||||
export class DatasourceSrv {
|
||||
datasources: { [name: string]: DataSourceApi };
|
||||
@ -21,12 +21,18 @@ export class DatasourceSrv {
|
||||
this.datasources = {};
|
||||
}
|
||||
|
||||
get(name?: string): Promise<DataSourceApi> {
|
||||
get(name?: string, scopedVars?: ScopedVars): Promise<DataSourceApi> {
|
||||
if (!name) {
|
||||
return this.get(config.defaultDatasource);
|
||||
}
|
||||
|
||||
name = this.templateSrv.replace(name);
|
||||
// Interpolation here is to support template variable in data source selection
|
||||
name = this.templateSrv.replace(name, scopedVars, (value, variable) => {
|
||||
if (Array.isArray(value)) {
|
||||
return value[0];
|
||||
}
|
||||
return value;
|
||||
});
|
||||
|
||||
if (name === 'default') {
|
||||
return this.get(config.defaultDatasource);
|
||||
|
@ -1,11 +1,13 @@
|
||||
import kbn from 'app/core/utils/kbn';
|
||||
import { Variable, containsVariable, assignModelProperties, variableTypes } from './variable';
|
||||
import { stringToJsRegex } from '@grafana/ui';
|
||||
|
||||
export class DatasourceVariable implements Variable {
|
||||
regex: any;
|
||||
query: string;
|
||||
options: any;
|
||||
current: any;
|
||||
multi: boolean;
|
||||
includeAll: boolean;
|
||||
refresh: any;
|
||||
skipUrlSync: boolean;
|
||||
|
||||
@ -18,6 +20,8 @@ export class DatasourceVariable implements Variable {
|
||||
regex: '',
|
||||
options: [],
|
||||
query: '',
|
||||
multi: false,
|
||||
includeAll: false,
|
||||
refresh: 1,
|
||||
skipUrlSync: false,
|
||||
};
|
||||
@ -47,7 +51,7 @@ export class DatasourceVariable implements Variable {
|
||||
|
||||
if (this.regex) {
|
||||
regex = this.templateSrv.replace(this.regex, null, 'regex');
|
||||
regex = kbn.stringToJsRegex(regex);
|
||||
regex = stringToJsRegex(regex);
|
||||
}
|
||||
|
||||
for (let i = 0; i < sources.length; i++) {
|
||||
@ -69,9 +73,16 @@ export class DatasourceVariable implements Variable {
|
||||
}
|
||||
|
||||
this.options = options;
|
||||
if (this.includeAll) {
|
||||
this.addAllOption();
|
||||
}
|
||||
return this.variableSrv.validateVariableSelectionState(this);
|
||||
}
|
||||
|
||||
addAllOption() {
|
||||
this.options.unshift({ text: 'All', value: '$__all' });
|
||||
}
|
||||
|
||||
dependsOn(variable) {
|
||||
if (this.regex) {
|
||||
return containsVariable(this.regex, variable.name);
|
||||
@ -84,6 +95,9 @@ export class DatasourceVariable implements Variable {
|
||||
}
|
||||
|
||||
getValueForUrl() {
|
||||
if (this.current.text === 'All') {
|
||||
return 'All';
|
||||
}
|
||||
return this.current.value;
|
||||
}
|
||||
}
|
||||
@ -91,5 +105,6 @@ export class DatasourceVariable implements Variable {
|
||||
variableTypes['datasource'] = {
|
||||
name: 'Datasource',
|
||||
ctor: DatasourceVariable,
|
||||
supportsMulti: true,
|
||||
description: 'Enabled you to dynamically switch the datasource for multiple panels',
|
||||
};
|
||||
|
@ -1,6 +1,6 @@
|
||||
import _ from 'lodash';
|
||||
import kbn from 'app/core/utils/kbn';
|
||||
import { Variable, containsVariable, assignModelProperties, variableTypes } from './variable';
|
||||
import { stringToJsRegex } from '@grafana/ui';
|
||||
|
||||
function getNoneOption() {
|
||||
return { text: 'None', value: '', isNone: true };
|
||||
@ -148,7 +148,7 @@ export class QueryVariable implements Variable {
|
||||
options = [];
|
||||
|
||||
if (this.regex) {
|
||||
regex = kbn.stringToJsRegex(this.templateSrv.replace(this.regex, {}, 'regex'));
|
||||
regex = stringToJsRegex(this.templateSrv.replace(this.regex, {}, 'regex'));
|
||||
}
|
||||
for (i = 0; i < metricNames.length; i++) {
|
||||
const item = metricNames[i];
|
||||
|
@ -13,7 +13,7 @@
|
||||
</div>
|
||||
<div class="login-form">
|
||||
<input type="password" name="password" class="gf-form-input login-form-input" required ng-model="formModel.password" id="inputPassword"
|
||||
placeholder="password">
|
||||
placeholder="{{passwordHint}}">
|
||||
</div>
|
||||
<div class="login-button-group">
|
||||
<button type="submit" class="btn btn-large p-x-2" ng-if="!loggingIn" ng-click="submit();" ng-class="{'btn-inverse': !loginForm.$valid, 'btn-primary': loginForm.$valid}">
|
||||
|
@ -1,5 +1,6 @@
|
||||
import _ from 'lodash';
|
||||
import TableModel from 'app/core/table_model';
|
||||
import { TimeSeries } from '@grafana/ui';
|
||||
|
||||
export class ResultTransformer {
|
||||
constructor(private templateSrv) {}
|
||||
@ -18,10 +19,10 @@ export class ResultTransformer {
|
||||
];
|
||||
} else if (prometheusResult && options.format === 'heatmap') {
|
||||
let seriesList = [];
|
||||
prometheusResult.sort(sortSeriesByLabel);
|
||||
for (const metricData of prometheusResult) {
|
||||
seriesList.push(this.transformMetricData(metricData, options, options.start, options.end));
|
||||
}
|
||||
seriesList.sort(sortSeriesByLabel);
|
||||
seriesList = this.transformToHistogramOverTime(seriesList);
|
||||
return seriesList;
|
||||
} else if (prometheusResult) {
|
||||
@ -197,13 +198,13 @@ export class ResultTransformer {
|
||||
}
|
||||
}
|
||||
|
||||
function sortSeriesByLabel(s1, s2): number {
|
||||
function sortSeriesByLabel(s1: TimeSeries, s2: TimeSeries): number {
|
||||
let le1, le2;
|
||||
|
||||
try {
|
||||
// fail if not integer. might happen with bad queries
|
||||
le1 = parseHistogramLabel(s1.metric.le);
|
||||
le2 = parseHistogramLabel(s2.metric.le);
|
||||
le1 = parseHistogramLabel(s1.target);
|
||||
le2 = parseHistogramLabel(s2.target);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
return 0;
|
||||
|
@ -55,6 +55,7 @@ const panelDefaults = {
|
||||
showHistogram: false,
|
||||
},
|
||||
highlightCards: true,
|
||||
hideZeroBuckets: false,
|
||||
};
|
||||
|
||||
const colorModes = ['opacity', 'spectrum'];
|
||||
@ -97,7 +98,7 @@ const colorSchemes = [
|
||||
{ name: 'YlOrRd', value: 'interpolateYlOrRd', invert: 'dark' },
|
||||
];
|
||||
|
||||
const dsSupportHistogramSort = ['prometheus', 'elasticsearch'];
|
||||
const dsSupportHistogramSort = ['elasticsearch'];
|
||||
|
||||
export class HeatmapCtrl extends MetricsPanelCtrl {
|
||||
static templateUrl = 'module.html';
|
||||
@ -204,7 +205,7 @@ export class HeatmapCtrl extends MetricsPanelCtrl {
|
||||
yBucketSize = 1;
|
||||
}
|
||||
|
||||
const { cards, cardStats } = convertToCards(bucketsData);
|
||||
const { cards, cardStats } = convertToCards(bucketsData, this.panel.hideZeroBuckets);
|
||||
|
||||
this.data = {
|
||||
buckets: bucketsData,
|
||||
@ -246,7 +247,7 @@ export class HeatmapCtrl extends MetricsPanelCtrl {
|
||||
// Always let yBucketSize=1 in 'tsbuckets' mode
|
||||
yBucketSize = 1;
|
||||
|
||||
const { cards, cardStats } = convertToCards(bucketsData);
|
||||
const { cards, cardStats } = convertToCards(bucketsData, this.panel.hideZeroBuckets);
|
||||
|
||||
this.data = {
|
||||
buckets: bucketsData,
|
||||
|
@ -93,25 +93,43 @@ function parseHistogramLabel(label: string): number {
|
||||
return value;
|
||||
}
|
||||
|
||||
interface HeatmapCard {
|
||||
x: number;
|
||||
y: number;
|
||||
yBounds: {
|
||||
top: number | null;
|
||||
bottom: number | null;
|
||||
};
|
||||
values: number[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface HeatmapCardStats {
|
||||
min: number;
|
||||
max: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert buckets into linear array of "cards" - objects, represented heatmap elements.
|
||||
* @param {Object} buckets
|
||||
* @return {Array} Array of "card" objects
|
||||
* @return {Object} Array of "card" objects and stats
|
||||
*/
|
||||
function convertToCards(buckets) {
|
||||
function convertToCards(buckets: any, hideZero = false): { cards: HeatmapCard[]; cardStats: HeatmapCardStats } {
|
||||
let min = 0,
|
||||
max = 0;
|
||||
const cards = [];
|
||||
const cards: HeatmapCard[] = [];
|
||||
_.forEach(buckets, xBucket => {
|
||||
_.forEach(xBucket.buckets, yBucket => {
|
||||
const card = {
|
||||
const card: HeatmapCard = {
|
||||
x: xBucket.x,
|
||||
y: yBucket.y,
|
||||
yBounds: yBucket.bounds,
|
||||
values: yBucket.values,
|
||||
count: yBucket.count,
|
||||
};
|
||||
if (!hideZero || card.count !== 0) {
|
||||
cards.push(card);
|
||||
}
|
||||
|
||||
if (cards.length === 1) {
|
||||
min = yBucket.count;
|
||||
|
@ -63,6 +63,10 @@
|
||||
|
||||
<div class="section gf-form-group">
|
||||
<h5 class="section-heading">Buckets</h5>
|
||||
<gf-form-switch class="gf-form" label-class="width-8"
|
||||
label="Hide zero"
|
||||
checked="ctrl.panel.hideZeroBuckets" on-change="ctrl.render()">
|
||||
</gf-form-switch>
|
||||
<div class="gf-form">
|
||||
<label class="gf-form-label width-8">Space</label>
|
||||
<input type="number" class="gf-form-input width-5" placeholder="auto" data-placement="right" bs-tooltip="''" ng-model="ctrl.panel.cards.cardPadding" ng-change="ctrl.refresh()" ng-model-onblur>
|
||||
|
@ -1,7 +1,6 @@
|
||||
import _ from 'lodash';
|
||||
import moment from 'moment';
|
||||
import kbn from 'app/core/utils/kbn';
|
||||
import { getValueFormat, getColorFromHexRgbOrName, GrafanaThemeType } from '@grafana/ui';
|
||||
import { getValueFormat, getColorFromHexRgbOrName, GrafanaThemeType, stringToJsRegex } from '@grafana/ui';
|
||||
|
||||
export class TableRenderer {
|
||||
formatters: any[];
|
||||
@ -35,7 +34,7 @@ export class TableRenderer {
|
||||
for (let i = 0; i < this.panel.styles.length; i++) {
|
||||
const style = this.panel.styles[i];
|
||||
|
||||
const regex = kbn.stringToJsRegex(style.pattern);
|
||||
const regex = stringToJsRegex(style.pattern);
|
||||
if (column.text.match(regex)) {
|
||||
column.style = style;
|
||||
|
||||
|
90
public/app/plugins/panel/text2/TextPanel.tsx
Normal file
90
public/app/plugins/panel/text2/TextPanel.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
// Libraries
|
||||
import React, { PureComponent } from 'react';
|
||||
import Remarkable from 'remarkable';
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
// Utils
|
||||
import { sanitize } from 'app/core/utils/text';
|
||||
import config from 'app/core/config';
|
||||
|
||||
// Types
|
||||
import { TextOptions } from './types';
|
||||
import { PanelProps } from '@grafana/ui/src/types';
|
||||
|
||||
interface Props extends PanelProps<TextOptions> {}
|
||||
interface State {
|
||||
html: string;
|
||||
}
|
||||
|
||||
export class TextPanel extends PureComponent<Props, State> {
|
||||
remarkable: Remarkable;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
html: this.processContent(props.options),
|
||||
};
|
||||
}
|
||||
|
||||
updateHTML = debounce(() => {
|
||||
const html = this.processContent(this.props.options);
|
||||
if (html !== this.state.html) {
|
||||
this.setState({ html });
|
||||
}
|
||||
}, 150);
|
||||
|
||||
componentDidUpdate(prevProps: Props) {
|
||||
// Since any change could be referenced in a template variable,
|
||||
// This needs to process everytime (with debounce)
|
||||
this.updateHTML();
|
||||
}
|
||||
|
||||
prepareHTML(html: string): string {
|
||||
const { replaceVariables } = this.props;
|
||||
|
||||
html = config.disableSanitizeHtml ? html : sanitize(html);
|
||||
|
||||
return replaceVariables(html);
|
||||
}
|
||||
|
||||
prepareText(content: string): string {
|
||||
return this.prepareHTML(
|
||||
content
|
||||
.replace(/&/g, '&')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/</g, '<')
|
||||
.replace(/\n/g, '<br/>')
|
||||
);
|
||||
}
|
||||
|
||||
prepareMarkdown(content: string): string {
|
||||
if (!this.remarkable) {
|
||||
this.remarkable = new Remarkable();
|
||||
}
|
||||
return this.prepareHTML(this.remarkable.render(content));
|
||||
}
|
||||
|
||||
processContent(options: TextOptions): string {
|
||||
const { mode, content } = options;
|
||||
|
||||
if (!content) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (mode === 'markdown') {
|
||||
return this.prepareMarkdown(content);
|
||||
}
|
||||
if (mode === 'html') {
|
||||
return this.prepareHTML(content);
|
||||
}
|
||||
|
||||
return this.prepareText(content);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { html } = this.state;
|
||||
|
||||
return <div className="markdown-html panel-text-content" dangerouslySetInnerHTML={{ __html: html }} />;
|
||||
}
|
||||
}
|
38
public/app/plugins/panel/text2/TextPanelEditor.tsx
Normal file
38
public/app/plugins/panel/text2/TextPanelEditor.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
// Libraries
|
||||
import React, { PureComponent, ChangeEvent } from 'react';
|
||||
|
||||
// Components
|
||||
import { PanelEditorProps, PanelOptionsGroup, Select, SelectOptionItem } from '@grafana/ui';
|
||||
|
||||
// Types
|
||||
import { TextOptions } from './types';
|
||||
|
||||
export class TextPanelEditor extends PureComponent<PanelEditorProps<TextOptions>> {
|
||||
modes: SelectOptionItem[] = [
|
||||
{ value: 'markdown', label: 'Markdown' },
|
||||
{ value: 'text', label: 'Text' },
|
||||
{ value: 'html', label: 'HTML' },
|
||||
];
|
||||
|
||||
onModeChange = (item: SelectOptionItem) => this.props.onOptionsChange({ ...this.props.options, mode: item.value });
|
||||
|
||||
onContentChange = (evt: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
this.props.onOptionsChange({ ...this.props.options, content: (event.target as any).value });
|
||||
};
|
||||
|
||||
render() {
|
||||
const { mode, content } = this.props.options;
|
||||
|
||||
return (
|
||||
<PanelOptionsGroup title="Text">
|
||||
<div className="gf-form-inline">
|
||||
<div className="gf-form">
|
||||
<span className="gf-form-label">Mode</span>
|
||||
<Select onChange={this.onModeChange} value={this.modes.find(e => mode === e.value)} options={this.modes} />
|
||||
</div>
|
||||
</div>
|
||||
<textarea value={content} onChange={this.onContentChange} className="gf-form-input" rows={10} />
|
||||
</PanelOptionsGroup>
|
||||
);
|
||||
}
|
||||
}
|
@ -1,14 +1,10 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import { PanelProps, ReactPanelPlugin } from '@grafana/ui';
|
||||
import { ReactPanelPlugin } from '@grafana/ui';
|
||||
|
||||
export class Text2 extends PureComponent<PanelProps> {
|
||||
constructor(props: PanelProps) {
|
||||
super(props);
|
||||
}
|
||||
import { TextPanelEditor } from './TextPanelEditor';
|
||||
import { TextPanel } from './TextPanel';
|
||||
import { TextOptions, defaults } from './types';
|
||||
|
||||
render() {
|
||||
return <h2>Text Panel!</h2>;
|
||||
}
|
||||
}
|
||||
export const reactPanel = new ReactPanelPlugin<TextOptions>(TextPanel);
|
||||
|
||||
export const reactPanel = new ReactPanelPlugin(Text2);
|
||||
reactPanel.setEditor(TextPanelEditor);
|
||||
reactPanel.setDefaults(defaults);
|
||||
|
14
public/app/plugins/panel/text2/types.ts
Normal file
14
public/app/plugins/panel/text2/types.ts
Normal file
@ -0,0 +1,14 @@
|
||||
export interface TextOptions {
|
||||
mode: 'html' | 'markdown' | 'text';
|
||||
content: string;
|
||||
}
|
||||
|
||||
export const defaults: TextOptions = {
|
||||
mode: 'markdown',
|
||||
content: `# Title
|
||||
|
||||
For markdown syntax help: [commonmark.org/help](https://commonmark.org/help/)
|
||||
|
||||
|
||||
`,
|
||||
};
|
@ -75,27 +75,22 @@ export class GrafanaCtrl {
|
||||
}
|
||||
}
|
||||
|
||||
function setViewModeBodyClass(body: JQuery, mode: KioskUrlValue, sidemenuOpen: boolean) {
|
||||
function setViewModeBodyClass(body: JQuery, mode: KioskUrlValue) {
|
||||
body.removeClass('view-mode--tv');
|
||||
body.removeClass('view-mode--kiosk');
|
||||
body.removeClass('view-mode--inactive');
|
||||
|
||||
switch (mode) {
|
||||
case 'tv': {
|
||||
body.removeClass('sidemenu-open');
|
||||
body.addClass('view-mode--tv');
|
||||
break;
|
||||
}
|
||||
// 1 & true for legacy states
|
||||
case '1':
|
||||
case true: {
|
||||
body.removeClass('sidemenu-open');
|
||||
body.addClass('view-mode--kiosk');
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
body.toggleClass('sidemenu-open', sidemenuOpen);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -105,7 +100,6 @@ export function grafanaAppDirective(playlistSrv, contextSrv, $timeout, $rootScop
|
||||
restrict: 'E',
|
||||
controller: GrafanaCtrl,
|
||||
link: (scope, elem) => {
|
||||
let sidemenuOpen;
|
||||
const body = $('body');
|
||||
|
||||
// see https://github.com/zenorocha/clipboard.js/issues/155
|
||||
@ -113,14 +107,6 @@ export function grafanaAppDirective(playlistSrv, contextSrv, $timeout, $rootScop
|
||||
|
||||
$('.preloader').remove();
|
||||
|
||||
sidemenuOpen = scope.contextSrv.sidemenu;
|
||||
body.toggleClass('sidemenu-open', sidemenuOpen);
|
||||
|
||||
appEvents.on('toggle-sidemenu', () => {
|
||||
sidemenuOpen = scope.contextSrv.sidemenu;
|
||||
body.toggleClass('sidemenu-open');
|
||||
});
|
||||
|
||||
appEvents.on('toggle-sidemenu-mobile', () => {
|
||||
body.toggleClass('sidemenu-open--xs');
|
||||
});
|
||||
@ -163,7 +149,7 @@ export function grafanaAppDirective(playlistSrv, contextSrv, $timeout, $rootScop
|
||||
$('#tooltip, .tooltip').remove();
|
||||
|
||||
// check for kiosk url param
|
||||
setViewModeBodyClass(body, data.params.kiosk, sidemenuOpen);
|
||||
setViewModeBodyClass(body, data.params.kiosk);
|
||||
|
||||
// close all drops
|
||||
for (const drop of Drop.drops) {
|
||||
@ -198,7 +184,7 @@ export function grafanaAppDirective(playlistSrv, contextSrv, $timeout, $rootScop
|
||||
}
|
||||
|
||||
$timeout(() => $location.search(search));
|
||||
setViewModeBodyClass(body, search.kiosk, sidemenuOpen);
|
||||
setViewModeBodyClass(body, search.kiosk);
|
||||
});
|
||||
|
||||
// handle in active view state class
|
||||
@ -218,7 +204,6 @@ export function grafanaAppDirective(playlistSrv, contextSrv, $timeout, $rootScop
|
||||
if (new Date().getTime() - lastActivity > inActiveTimeLimit) {
|
||||
activeUser = false;
|
||||
body.addClass('view-mode--inactive');
|
||||
body.removeClass('sidemenu-open');
|
||||
}
|
||||
}
|
||||
|
||||
@ -227,7 +212,6 @@ export function grafanaAppDirective(playlistSrv, contextSrv, $timeout, $rootScop
|
||||
if (!activeUser) {
|
||||
activeUser = true;
|
||||
body.removeClass('view-mode--inactive');
|
||||
body.toggleClass('sidemenu-open', sidemenuOpen);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -157,15 +157,9 @@
|
||||
|
||||
@include media-breakpoint-up(sm) {
|
||||
.navbar {
|
||||
padding-left: 60px;
|
||||
}
|
||||
|
||||
.sidemenu-open {
|
||||
.navbar {
|
||||
padding-left: 25px;
|
||||
padding-left: 20px;
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.navbar-page-btn {
|
||||
.gicon {
|
||||
|
@ -63,6 +63,7 @@ $column-horizontal-spacing: 10px;
|
||||
font-size: $font-size-sm;
|
||||
display: table;
|
||||
table-layout: fixed;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.logs-row {
|
||||
@ -83,16 +84,22 @@ $column-horizontal-spacing: 10px;
|
||||
|
||||
.logs-row__time {
|
||||
white-space: nowrap;
|
||||
width: 19em;
|
||||
}
|
||||
|
||||
.logs-row__localtime {
|
||||
white-space: nowrap;
|
||||
width: 12.5em;
|
||||
}
|
||||
|
||||
.logs-row__labels {
|
||||
max-width: 20%;
|
||||
width: 20%;
|
||||
line-height: 1.2;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.logs-row__message {
|
||||
word-break: break-all;
|
||||
min-width: 80%;
|
||||
}
|
||||
|
||||
.logs-row__match-highlight {
|
||||
@ -112,6 +119,7 @@ $column-horizontal-spacing: 10px;
|
||||
|
||||
.logs-row__level {
|
||||
position: relative;
|
||||
width: 10px;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
@ -165,6 +173,7 @@ $column-horizontal-spacing: 10px;
|
||||
|
||||
.logs-row__duplicates {
|
||||
text-align: right;
|
||||
width: 4.5em;
|
||||
}
|
||||
|
||||
.logs-row__field-highlight {
|
||||
@ -193,15 +202,20 @@ $column-horizontal-spacing: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.logs-labels {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.logs-label {
|
||||
display: inline-block;
|
||||
display: flex;
|
||||
padding: 0 2px;
|
||||
background-color: $btn-inverse-bg;
|
||||
border-radius: $border-radius;
|
||||
margin: 0 4px 2px 0;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.logs-label__icon {
|
||||
@ -211,6 +225,13 @@ $column-horizontal-spacing: 10px;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.logs-label__value {
|
||||
display: inline-block;
|
||||
max-width: 20em;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.logs-label__stats {
|
||||
position: absolute;
|
||||
top: 1.25em;
|
||||
|
@ -16,6 +16,14 @@
|
||||
.sidemenu__close {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@include media-breakpoint-up(sm) {
|
||||
background: $side-menu-bg;
|
||||
height: auto;
|
||||
box-shadow: $side-menu-shadow;
|
||||
position: relative;
|
||||
z-index: $zindex-sidemenu;
|
||||
}
|
||||
}
|
||||
|
||||
// body class that hides sidemenu
|
||||
@ -25,32 +33,22 @@
|
||||
}
|
||||
}
|
||||
|
||||
@include media-breakpoint-up(sm) {
|
||||
.sidemenu-open {
|
||||
.sidemenu {
|
||||
background: $side-menu-bg;
|
||||
height: auto;
|
||||
box-shadow: $side-menu-shadow;
|
||||
position: relative;
|
||||
z-index: $zindex-sidemenu;
|
||||
}
|
||||
|
||||
.sidemenu__top,
|
||||
.sidemenu__bottom {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sidemenu__top {
|
||||
padding-top: 3rem;
|
||||
flex-grow: 1;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidemenu__bottom {
|
||||
padding-bottom: $spacer;
|
||||
}
|
||||
|
||||
.sidemenu__top,
|
||||
.sidemenu__bottom {
|
||||
display: none;
|
||||
|
||||
@include media-breakpoint-up(sm) {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.sidemenu-item {
|
||||
|
@ -21,10 +21,8 @@
|
||||
border-radius: 3px;
|
||||
text-shadow: none;
|
||||
font-size: 13px;
|
||||
padding: 3px 6px 1px 6px;
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
box-shadow: 0 0 1px rgba($white, 0.2);
|
||||
padding: 2px 6px 2px 6px;
|
||||
border: 1px solid lighten($purple, 10%);
|
||||
|
||||
.icon-tag {
|
||||
position: relative;
|
||||
|
@ -29,6 +29,21 @@
|
||||
.view-mode--tv {
|
||||
@extend .view-mode--inactive;
|
||||
|
||||
.sidemenu {
|
||||
position: fixed;
|
||||
background-color: transparent;
|
||||
box-shadow: none;
|
||||
|
||||
.sidemenu__top,
|
||||
.sidemenu__bottom {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.navbar {
|
||||
padding-left: $side-menu-width;
|
||||
}
|
||||
|
||||
.submenu-controls {
|
||||
display: none;
|
||||
}
|
||||
|
@ -25,20 +25,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
.sidemenu-open {
|
||||
.explore-toolbar-header {
|
||||
padding: 0;
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.explore-toolbar {
|
||||
background: inherit;
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
justify-content: flex-start;
|
||||
height: auto;
|
||||
padding: 0px $dashboard-padding 0 25px;
|
||||
padding: 0 $dashboard-padding;
|
||||
border-bottom: 1px solid #0000;
|
||||
transition-duration: 0.35s;
|
||||
transition-timing-function: ease-in-out;
|
||||
@ -134,20 +127,6 @@
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 803px) {
|
||||
.sidemenu-open {
|
||||
.explore-toolbar-header-title {
|
||||
.navbar-page-btn {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.explore-toolbar-header-title {
|
||||
.navbar-page-btn {
|
||||
margin-left: $dashboard-padding;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-title {
|
||||
display: none;
|
||||
}
|
||||
@ -161,14 +140,6 @@
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 544px) {
|
||||
.sidemenu-open {
|
||||
.explore-toolbar-header-title {
|
||||
.navbar-page-btn {
|
||||
margin-left: $dashboard-padding;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.explore-toolbar-header-title {
|
||||
.navbar-page-btn {
|
||||
margin-left: $dashboard-padding;
|
||||
|
@ -33,10 +33,12 @@ program
|
||||
.description('Prepares @grafana/ui release (and publishes to npm on demand)')
|
||||
.option('-p, --publish', 'Publish @grafana/ui to npm registry')
|
||||
.option('-u, --usePackageJsonVersion', 'Use version specified in package.json')
|
||||
.option('--createVersionCommit', 'Create and push version commit')
|
||||
.action(async cmd => {
|
||||
await execTask(releaseTask)({
|
||||
publishToNpm: !!cmd.publish,
|
||||
usePackageJsonVersion: !!cmd.usePackageJsonVersion,
|
||||
createVersionCommit: !!cmd.createVersionCommit,
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -9,10 +9,10 @@ import { savePackage, buildTask } from './grafanaui.build';
|
||||
import { TaskRunner, Task } from './task';
|
||||
|
||||
type VersionBumpType = 'prerelease' | 'patch' | 'minor' | 'major';
|
||||
|
||||
interface ReleaseTaskOptions {
|
||||
publishToNpm: boolean;
|
||||
usePackageJsonVersion: boolean;
|
||||
createVersionCommit: boolean;
|
||||
}
|
||||
|
||||
const promptBumpType = async () => {
|
||||
@ -62,6 +62,12 @@ const promptConfirm = async (message?: string) => {
|
||||
]);
|
||||
};
|
||||
|
||||
// Since Grafana core depends on @grafana/ui highly, we run full check before release
|
||||
const runChecksAndTests = async () =>
|
||||
useSpinner<void>(`Running checks and tests`, async () => {
|
||||
await execa('npm', ['run', 'test']);
|
||||
})();
|
||||
|
||||
const bumpVersion = (version: string) =>
|
||||
useSpinner<void>(`Saving version ${version} to package.json`, async () => {
|
||||
changeCwdToGrafanaUi();
|
||||
@ -94,8 +100,21 @@ const ensureMasterBranch = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const releaseTaskRunner: TaskRunner<ReleaseTaskOptions> = async ({ publishToNpm, usePackageJsonVersion }) => {
|
||||
const prepareVersionCommitAndPush = async (version: string) =>
|
||||
useSpinner<void>('Commiting and pushing @grafana/ui version update', async () => {
|
||||
await execa.stdout('git', ['commit', '-a', '-m', `Upgrade @grafana/ui version to v${version}`]);
|
||||
await execa.stdout('git', ['push']);
|
||||
})();
|
||||
|
||||
const releaseTaskRunner: TaskRunner<ReleaseTaskOptions> = async ({
|
||||
publishToNpm,
|
||||
usePackageJsonVersion,
|
||||
createVersionCommit,
|
||||
}) => {
|
||||
await runChecksAndTests();
|
||||
if (publishToNpm) {
|
||||
// TODO: Ensure release branch
|
||||
// When need to update this when we star keeping @grafana/ui releases in sync with core
|
||||
await ensureMasterBranch();
|
||||
}
|
||||
|
||||
@ -145,10 +164,15 @@ const releaseTaskRunner: TaskRunner<ReleaseTaskOptions> = async ({ publishToNpm,
|
||||
await bumpVersion(nextVersion);
|
||||
}
|
||||
|
||||
if (createVersionCommit) {
|
||||
await prepareVersionCommitAndPush(nextVersion);
|
||||
}
|
||||
|
||||
if (publishToNpm) {
|
||||
await publishPackage(pkg.name, nextVersion);
|
||||
console.log(chalk.green(`\nVersion ${nextVersion} of ${pkg.name} succesfully released!`));
|
||||
console.log(chalk.yellow(`\nUpdated @grafana/ui/package.json with version bump created - COMMIT THIS FILE!`));
|
||||
console.log(chalk.yellow(`\nUpdated @grafana/ui/package.json with version bump created.`));
|
||||
|
||||
process.exit();
|
||||
} else {
|
||||
console.log(
|
||||
|
@ -4,7 +4,7 @@ type FnToSpin<T> = (options: T) => Promise<void>;
|
||||
|
||||
export const useSpinner = <T>(spinnerLabel: string, fn: FnToSpin<T>, killProcess = true) => {
|
||||
return async (options: T) => {
|
||||
const spinner = new ora(spinnerLabel);
|
||||
const spinner = ora(spinnerLabel);
|
||||
spinner.start();
|
||||
try {
|
||||
await fn(options);
|
||||
|
12
yarn.lock
12
yarn.lock
@ -1801,6 +1801,13 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-8.10.40.tgz#4314888d5cd537945d73e9ce165c04cc550144a4"
|
||||
integrity sha512-RRSjdwz63kS4u7edIwJUn8NqKLLQ6LyqF/X4+4jp38MBT3Vwetewi2N4dgJEshLbDwNgOJXNYoOwzVZUSSLhkQ==
|
||||
|
||||
"@types/papaparse@^4.5.9":
|
||||
version "4.5.9"
|
||||
resolved "https://registry.yarnpkg.com/@types/papaparse/-/papaparse-4.5.9.tgz#ff887bd362f57cd0c87320d2de38ac232bb55e81"
|
||||
integrity sha512-8Pmxp2IEd/y58tOIsiZkCbAkcKI7InYVpwZFVKJyweCVnqnVahKXVjfSo6gvxUVykQsJvtWB+s6Kc60znVfQVw==
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/prop-types@*":
|
||||
version "15.5.8"
|
||||
resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.5.8.tgz#8ae4e0ea205fe95c3901a5a1df7f66495e3a56ce"
|
||||
@ -12993,6 +13000,11 @@ pako@~1.0.5:
|
||||
resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.8.tgz#6844890aab9c635af868ad5fecc62e8acbba3ea4"
|
||||
integrity sha512-6i0HVbUfcKaTv+EG8ZTr75az7GFXcLYk9UyLEg7Notv/Ma+z/UG3TCoz6GiNeOrn1E/e63I0X/Hpw18jHOTUnA==
|
||||
|
||||
papaparse@^4.6.3:
|
||||
version "4.6.3"
|
||||
resolved "https://registry.yarnpkg.com/papaparse/-/papaparse-4.6.3.tgz#742e5eaaa97fa6c7e1358d2934d8f18f44aee781"
|
||||
integrity sha512-LRq7BrHC2kHPBYSD50aKuw/B/dGcg29omyJbKWY3KsYUZU69RKwaBHu13jGmCYBtOc4odsLCrFyk6imfyNubJQ==
|
||||
|
||||
parallel-transform@^1.1.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/parallel-transform/-/parallel-transform-1.1.0.tgz#d410f065b05da23081fcd10f28854c29bda33b06"
|
||||
|
Loading…
Reference in New Issue
Block a user