Merge branch 'master' into 8px-system-margins2

This commit is contained in:
Patrick O'Carroll 2019-03-12 12:41:41 +01:00 committed by GitHub
commit c2f5c12e5a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
84 changed files with 1189 additions and 274 deletions

View File

@ -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

View File

@ -5,4 +5,5 @@ pkg/
node_modules
public/vendor/
vendor/
data/

View File

@ -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.

View File

@ -7,12 +7,6 @@
Grafana is an open source, feature rich metrics dashboard and graph editor for
Graphite, Elasticsearch, OpenTSDB, Prometheus and InfluxDB.
![](https://www.grafanacon.org/2019/images/grafanacon_la_nav-logo.png)
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
<!---
![](http://docs.grafana.org/assets/img/features/dashboard_ex1.png)
-->

View File

@ -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

View File

@ -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

View File

@ -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.

View File

@ -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>

View File

@ -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]

View File

@ -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": {

View File

@ -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.

View File

@ -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",

View File

@ -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>
);

View File

@ -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;

View File

@ -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()}

View File

@ -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'

View File

@ -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');
}
};

View File

@ -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!');
}}
/>
);
});

View File

@ -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>
);
});

View File

@ -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();
});
});

View 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} &nbsp;
{hasErrors ? <i className="fa fa-exclamation-triangle" /> : <i className="fa fa-check-circle" />}
</footer>
</div>
)}
</AutoSizer>
);
}
}
export default TableInputCSV;

View 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;
}

View File

@ -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';

View File

@ -53,7 +53,7 @@ export interface TimeSeriesVMs {
length: number;
}
interface Column {
export interface Column {
text: string;
title?: string;
type?: string;

View File

@ -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",
}
`;

View 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;

View File

@ -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';

View 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();
});
});
});

View 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: {},
});
}

View File

@ -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;

View 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();
});
});

View 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]);
}

View File

@ -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 {

View File

@ -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 {

View File

@ -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{}

View File

@ -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 = "![](" + picUrl + ")\\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)

View File

@ -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

View File

@ -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
}

View File

@ -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 {

View File

@ -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()

View File

@ -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

View File

@ -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)

View File

@ -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() {

View File

@ -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>
);

View File

@ -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;

View File

@ -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">

View File

@ -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"

View File

@ -34,6 +34,7 @@ export class Settings {
oauth: any;
disableUserSignUp: boolean;
loginHint: any;
passwordHint: any;
loginError: any;
viewersCanEdit: boolean;
editorsCanOwn: boolean;

View File

@ -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';

View File

@ -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;
}, []);

View File

@ -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;
}

View File

@ -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()', () => {

View File

@ -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;
}

View File

@ -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) => {

View File

@ -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);
}

View File

@ -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;

View File

@ -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>
);
}
}

View File

@ -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>
)}

View 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);
});
});

View File

@ -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))

View File

@ -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);

View File

@ -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',
};

View File

@ -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];

View File

@ -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}">

View File

@ -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;

View File

@ -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,

View File

@ -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;

View File

@ -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>

View File

@ -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;

View 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, '&amp;')
.replace(/>/g, '&gt;')
.replace(/</g, '&lt;')
.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 }} />;
}
}

View 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>
);
}
}

View File

@ -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);

View 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/)
`,
};

View File

@ -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);
}
}

View File

@ -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 {

View File

@ -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;

View File

@ -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 {

View File

@ -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;

View File

@ -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;
}

View File

@ -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;

View File

@ -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,
});
});

View File

@ -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(

View File

@ -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);

View File

@ -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"