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**: **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 ```release-note

View File

@ -5,4 +5,5 @@ pkg/
node_modules node_modules
public/vendor/ public/vendor/
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 ### 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). 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 ### 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. 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 filling up dashboard_versions quite fast if using
default settings. 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. 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 Grafana is an open source, feature rich metrics dashboard and graph editor for
Graphite, Elasticsearch, OpenTSDB, Prometheus and InfluxDB. 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) ![](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 # Background text for the user field on the login page
login_hint = email or username login_hint = email or username
password_hint = password
# Default UI theme ("dark" or "light") # Default UI theme ("dark" or "light")
default_theme = dark default_theme = dark

View File

@ -211,6 +211,7 @@ log_queries =
# Background text for the user field on the login page # Background text for the user field on the login page
;login_hint = email or username ;login_hint = email or username
;password_hint = password
# Default UI theme ("dark" or "light") # Default UI theme ("dark" or "light")
;default_theme = dark ;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 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 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. 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 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: send via POST body, which can be enabled via the following settings:
```bash ```bash
[auth.generic_oauth] [auth.generic_oauth]
send_client_credentials_via_post = true send_client_credentials_via_post = true
``` ```
<hr> <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. Viewers can edit/inspect dashboard settings in the browser. But not save the dashboard.
Defaults to `false`. 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> <hr>
## [auth] ## [auth]

View File

@ -139,7 +139,7 @@
"gui:build": "ts-node --project ./scripts/cli/tsconfig.json ./scripts/cli/index.ts gui:build", "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: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: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" "cli": "ts-node --project ./scripts/cli/tsconfig.json ./scripts/cli/index.ts"
}, },
"husky": { "husky": {

View File

@ -12,6 +12,36 @@ See [package source](https://github.com/grafana/grafana/tree/master/packages/gra
`npm install @grafana/ui` `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 ## Versioning
To limit the confusion related to @grafana/ui and Grafana versioning we decided to keep the major version in sync between those two. 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. 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", "jquery": "^3.2.1",
"lodash": "^4.17.10", "lodash": "^4.17.10",
"moment": "^2.22.2", "moment": "^2.22.2",
"papaparse": "^4.6.3",
"react": "^16.6.3", "react": "^16.6.3",
"react-color": "^2.17.0", "react-color": "^2.17.0",
"react-custom-scrollbars": "^4.2.1", "react-custom-scrollbars": "^4.2.1",
@ -46,6 +47,7 @@
"@types/jquery": "^1.10.35", "@types/jquery": "^1.10.35",
"@types/lodash": "^4.14.119", "@types/lodash": "^4.14.119",
"@types/node": "^10.12.18", "@types/node": "^10.12.18",
"@types/papaparse": "^4.5.9",
"@types/react": "^16.7.6", "@types/react": "^16.7.6",
"@types/react-custom-scrollbars": "^4.0.5", "@types/react-custom-scrollbars": "^4.0.5",
"@types/react-test-renderer": "^16.0.3", "@types/react-test-renderer": "^16.0.3",

View File

@ -39,7 +39,7 @@ class ColorInput extends React.PureComponent<ColorInputProps, ColorInputState> {
this.props.onChange(color); this.props.onChange(color);
}; };
handleChange = (event: React.SyntheticEvent<HTMLInputElement>) => { onChange = (event: React.SyntheticEvent<HTMLInputElement>) => {
const newColor = tinycolor(event.currentTarget.value); const newColor = tinycolor(event.currentTarget.value);
this.setState({ this.setState({
@ -51,7 +51,7 @@ class ColorInput extends React.PureComponent<ColorInputProps, ColorInputState> {
} }
}; };
handleBlur = () => { onBlur = () => {
const newColor = tinycolor(this.state.value); const newColor = tinycolor(this.state.value);
if (!newColor.isValid()) { if (!newColor.isValid()) {
@ -84,7 +84,7 @@ class ColorInput extends React.PureComponent<ColorInputProps, ColorInputState> {
flexGrow: 1, 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>
</div> </div>
); );

View File

@ -15,7 +15,7 @@ export const colorPickerFactory = <T extends ColorPickerProps>(
static displayName = displayName; static displayName = displayName;
pickerTriggerRef = createRef<HTMLDivElement>(); pickerTriggerRef = createRef<HTMLDivElement>();
handleColorChange = (color: string) => { onColorChange = (color: string) => {
const { onColorChange, onChange } = this.props; const { onColorChange, onChange } = this.props;
const changeHandler = (onColorChange || onChange) as ColorPickerChangeHandler; const changeHandler = (onColorChange || onChange) as ColorPickerChangeHandler;
@ -25,7 +25,7 @@ export const colorPickerFactory = <T extends ColorPickerProps>(
render() { render() {
const popoverElement = React.createElement(popover, { const popoverElement = React.createElement(popover, {
...this.props, ...this.props,
onChange: this.handleColorChange, onChange: this.onColorChange,
}); });
const { theme, children } = this.props; const { theme, children } = this.props;

View File

@ -60,7 +60,7 @@ export class ColorPickerPopover<T extends CustomPickersDescriptor> extends React
changeHandler(getColorFromHexRgbOrName(color, theme.type)); changeHandler(getColorFromHexRgbOrName(color, theme.type));
}; };
handleTabChange = (tab: PickerType | keyof T) => { onTabChange = (tab: PickerType | keyof T) => {
return () => this.setState({ activePicker: tab }); return () => this.setState({ activePicker: tab });
}; };
@ -104,7 +104,7 @@ export class ColorPickerPopover<T extends CustomPickersDescriptor> extends React
<> <>
{Object.keys(customPickers).map(key => { {Object.keys(customPickers).map(key => {
return ( 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} {customPickers[key].name}
</div> </div>
); );
@ -119,10 +119,10 @@ export class ColorPickerPopover<T extends CustomPickersDescriptor> extends React
return ( return (
<div className={`ColorPickerPopover ColorPickerPopover--${colorPickerTheme}`}> <div className={`ColorPickerPopover ColorPickerPopover--${colorPickerTheme}`}>
<div className="ColorPickerPopover__tabs"> <div className="ColorPickerPopover__tabs">
<div className={this.getTabClassName('palette')} onClick={this.handleTabChange('palette')}> <div className={this.getTabClassName('palette')} onClick={this.onTabChange('palette')}>
Colors Colors
</div> </div>
<div className={this.getTabClassName('spectrum')} onClick={this.handleTabChange('spectrum')}> <div className={this.getTabClassName('spectrum')} onClick={this.onTabChange('spectrum')}>
Custom Custom
</div> </div>
{this.renderCustomPickerTabs()} {this.renderCustomPickerTabs()}

View File

@ -8,7 +8,7 @@ import { renderComponentWithTheme } from '../../utils/storybook/withTheme';
import { UseState } from '../../utils/storybook/UseState'; import { UseState } from '../../utils/storybook/UseState';
const BasicGreen = getColorDefinitionByName('green'); const BasicGreen = getColorDefinitionByName('green');
const BasicBlue = getColorDefinitionByName('blue'); const BasicRed = getColorDefinitionByName('red');
const LightBlue = getColorDefinitionByName('light-blue'); const LightBlue = getColorDefinitionByName('light-blue');
const NamedColorsPaletteStories = storiesOf('UI/ColorPicker/Palettes/NamedColorsPalette', module); const NamedColorsPaletteStories = storiesOf('UI/ColorPicker/Palettes/NamedColorsPalette', module);
@ -41,7 +41,7 @@ NamedColorsPaletteStories.add('Named colors swatch - support for named colors',
'Selected color', 'Selected color',
{ {
Green: BasicGreen.variants.dark, Green: BasicGreen.variants.dark,
Red: BasicBlue.variants.dark, Red: BasicRed.variants.dark,
'Light blue': LightBlue.variants.dark, 'Light blue': LightBlue.variants.dark,
}, },
'red' 'red'

View File

@ -1,9 +1,9 @@
import propDeprecationWarning from '../../utils/propDeprecationWarning'; import deprecationWarning from '../../utils/deprecationWarning';
import { ColorPickerProps } from './ColorPickerPopover'; import { ColorPickerProps } from './ColorPickerPopover';
export const warnAboutColorPickerPropsDeprecation = (componentName: string, props: ColorPickerProps) => { export const warnAboutColorPickerPropsDeprecation = (componentName: string, props: ColorPickerProps) => {
const { onColorChange } = props; const { onColorChange } = props;
if (onColorChange) { 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 { storiesOf } from '@storybook/react';
import { DeleteButton } from './DeleteButton'; import { DeleteButton } from './DeleteButton';
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
const CenteredStory: FunctionComponent<{}> = ({ children }) => { import { action } from '@storybook/addon-actions';
return (
<div
style={{
height: '100vh ',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{children}
</div>
);
};
storiesOf('UI/DeleteButton', module) storiesOf('UI/DeleteButton', module)
.addDecorator(story => <CenteredStory>{story()}</CenteredStory>) .addDecorator(withCenteredStory)
.add('default', () => { .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 'CustomScrollbar/CustomScrollbar';
@import 'DeleteButton/DeleteButton'; @import 'DeleteButton/DeleteButton';
@import 'ThresholdsEditor/ThresholdsEditor'; @import 'ThresholdsEditor/ThresholdsEditor';
@import 'Table/TableInputCSV';
@import 'Tooltip/Tooltip'; @import 'Tooltip/Tooltip';
@import 'Select/Select'; @import 'Select/Select';
@import 'PanelOptionsGroup/PanelOptionsGroup'; @import 'PanelOptionsGroup/PanelOptionsGroup';

View File

@ -53,7 +53,7 @@ export interface TimeSeriesVMs {
length: number; length: number;
} }
interface Column { export interface Column {
text: string; text: string;
title?: string; title?: string;
type?: 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 './valueFormats/valueFormats';
export * from './colors'; export * from './colors';
export * from './namedColorsPalette'; export * from './namedColorsPalette';
export * from './string';
export * from './deprecationWarning';
export { getMappedValue } from './valueMappings'; 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["oauth"] = enabledOAuths
viewData.Settings["disableUserSignUp"] = !setting.AllowUserSignUp viewData.Settings["disableUserSignUp"] = !setting.AllowUserSignUp
viewData.Settings["loginHint"] = setting.LoginHint viewData.Settings["loginHint"] = setting.LoginHint
viewData.Settings["passwordHint"] = setting.PasswordHint
viewData.Settings["disableLoginForm"] = setting.DisableLoginForm viewData.Settings["disableLoginForm"] = setting.DisableLoginForm
if loginError, ok := tryGetEncryptedCookie(c, LoginErrorCookieName); ok { 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 { 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 // 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) a.log.Info("LDAP initial bind failed, %v", err)
if ldapErr, ok := err.(*ldap.Error); ok { 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() { Convey("When translating ldap user to grafana user", t, func() {
var user1 = &m.User{} var user1 = &m.User{}

View File

@ -1,6 +1,10 @@
package notifiers package notifiers
import ( import (
"fmt"
"net/url"
"strings"
"github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/log"
@ -8,19 +12,26 @@ import (
"github.com/grafana/grafana/pkg/services/alerting" "github.com/grafana/grafana/pkg/services/alerting"
) )
func init() { const DefaultDingdingMsgType = "link"
alerting.RegisterNotifier(&alerting.NotifierPlugin{ const DingdingOptionsTemplate = `
Type: "dingding",
Name: "DingDing",
Description: "Sends HTTP POST request to DingDing",
Factory: NewDingDingNotifier,
OptionsTemplate: `
<h3 class="page-heading">DingDing settings</h3> <h3 class="page-heading">DingDing settings</h3>
<div class="gf-form"> <div class="gf-form">
<span class="gf-form-label width-10">Url</span> <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> <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>
`, <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: 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"} return nil, alerting.ValidationError{Reason: "Could not find url property in settings"}
} }
msgType := model.Settings.Get("msgType").MustString(DefaultDingdingMsgType)
return &DingDingNotifier{ return &DingDingNotifier{
NotifierBase: NewNotifierBase(model), NotifierBase: NewNotifierBase(model),
MsgType: msgType,
Url: url, Url: url,
log: log.New("alerting.notifier.dingding"), log: log.New("alerting.notifier.dingding"),
}, nil }, nil
@ -40,8 +54,9 @@ func NewDingDingNotifier(model *m.AlertNotification) (alerting.Notifier, error)
type DingDingNotifier struct { type DingDingNotifier struct {
NotifierBase NotifierBase
Url string MsgType string
log log.Logger Url string
log log.Logger
} }
func (this *DingDingNotifier) Notify(evalContext *alerting.EvalContext) error { func (this *DingDingNotifier) Notify(evalContext *alerting.EvalContext) error {
@ -52,6 +67,16 @@ func (this *DingDingNotifier) Notify(evalContext *alerting.EvalContext) error {
this.log.Error("Failed to get messageUrl", "error", err, "dingding", this.Name) this.log.Error("Failed to get messageUrl", "error", err, "dingding", this.Name)
messageUrl = "" 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) this.log.Info("messageUrl:" + messageUrl)
message := evalContext.Rule.Message message := evalContext.Rule.Message
@ -61,15 +86,39 @@ func (this *DingDingNotifier) Notify(evalContext *alerting.EvalContext) error {
message = title message = title
} }
bodyJSON, err := simplejson.NewJson([]byte(`{ for i, match := range evalContext.EvalMatches {
"msgtype": "link", message += fmt.Sprintf("\\n%2d. %s: %s", i+1, match.Metric, match.Value)
"link": { }
"text": "` + message + `",
"title": "` + title + `", var bodyStr string
"picUrl": "` + picUrl + `", if this.MsgType == "actionCard" {
"messageUrl": "` + messageUrl + `" // 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 + `",
"title": "` + title + `",
"picUrl": "` + picUrl + `",
"messageUrl": "` + messageUrl + `"
}
}`
}
bodyJSON, err := simplejson.NewJson([]byte(bodyStr))
if err != nil { if err != nil {
this.log.Error("Failed to create Json data", "error", err, "dingding", this.Name) 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) ctx := NewEvalContext(context.Background(), testRule)
if cmd.Settings.Get("uploadImage").MustBool(true) { 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.IsTestRun = true
ctx.Firing = true ctx.Firing = true

View File

@ -174,6 +174,11 @@ func UpdateDataSource(cmd *m.UpdateDataSourceCommand) error {
Version: cmd.Version + 1, Version: cmd.Version + 1,
} }
sess.UseBool("is_default")
sess.UseBool("basic_auth")
sess.UseBool("with_credentials")
sess.UseBool("read_only")
var updateSession *xorm.Session var updateSession *xorm.Session
if cmd.Version != 0 { if cmd.Version != 0 {
// the reason we allow cmd.version > db.version is make it possible for people to force // 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) 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 { if err != nil {
return err return err
} }

View File

@ -44,6 +44,10 @@ func DeleteOldLoginAttempts(cmd *m.DeleteOldLoginAttemptsCommand) error {
return err return err
} }
if result == nil || len(result) == 0 || result[0] == nil {
return nil
}
maxId = toInt64(result[0]["id"]) maxId = toInt64(result[0]["id"])
if maxId == 0 { if maxId == 0 {

View File

@ -109,6 +109,7 @@ var (
AutoAssignOrgRole string AutoAssignOrgRole string
VerifyEmailEnabled bool VerifyEmailEnabled bool
LoginHint string LoginHint string
PasswordHint string
DefaultTheme string DefaultTheme string
DisableLoginForm bool DisableLoginForm bool
DisableSignoutMenu 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"}) AutoAssignOrgRole = users.Key("auto_assign_org_role").In("Editor", []string{"Editor", "Admin", "Viewer"})
VerifyEmailEnabled = users.Key("verify_email_enabled").MustBool(false) VerifyEmailEnabled = users.Key("verify_email_enabled").MustBool(false)
LoginHint = users.Key("login_hint").String() LoginHint = users.Key("login_hint").String()
PasswordHint = users.Key("password_hint").String()
DefaultTheme = users.Key("default_theme").String() DefaultTheme = users.Key("default_theme").String()
ExternalUserMngLinkUrl = users.Key("external_manage_link_url").String() ExternalUserMngLinkUrl = users.Key("external_manage_link_url").String()
ExternalUserMngLinkName = users.Key("external_manage_link_name").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() alias := model.Get("alias").MustString()
if alias == "" {
alias = "{{metric}}_{{stat}}"
}
returnData := model.Get("returnData").MustBool(false) returnData := model.Get("returnData").MustBool(false)
highResolution := model.Get("highResolution").MustBool(false) highResolution := model.Get("highResolution").MustBool(false)
@ -521,7 +518,11 @@ func parseQuery(model *simplejson.Json) (*CloudWatchQuery, error) {
func formatAlias(query *CloudWatchQuery, stat string, dimensions map[string]string) string { func formatAlias(query *CloudWatchQuery, stat string, dimensions map[string]string) string {
if len(query.Id) > 0 && len(query.Expression) > 0 { if len(query.Id) > 0 && len(query.Expression) > 0 {
return query.Id if len(query.Alias) > 0 {
return query.Alias
} else {
return query.Id
}
} }
data := map[string]string{} data := map[string]string{}

View File

@ -279,7 +279,7 @@ func formatDate(t time.Time, pattern string) string {
isoYearShort := fmt.Sprintf("%d", isoYear)[2:4] isoYearShort := fmt.Sprintf("%d", isoYear)[2:4]
formatted = strings.Replace(formatted, "<stdIsoYear>", fmt.Sprintf("%d", isoYear), -1) formatted = strings.Replace(formatted, "<stdIsoYear>", fmt.Sprintf("%d", isoYear), -1)
formatted = strings.Replace(formatted, "<stdIsoYearShort>", isoYearShort, -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) 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, ShouldHaveLength, 1)
So(indices[0], ShouldEqual, "2018-data") 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() { Convey("Hourly interval", t, func() {

View File

@ -37,7 +37,7 @@ export const ToggleButton: FC<ToggleButtonProps> = ({
tooltip, tooltip,
onChange, onChange,
}) => { }) => {
const handleChange = event => { const onClick = event => {
event.stopPropagation(); event.stopPropagation();
if (onChange) { if (onChange) {
onChange(value); onChange(value);
@ -46,7 +46,7 @@ export const ToggleButton: FC<ToggleButtonProps> = ({
const btnClassName = `btn ${className} ${selected ? 'active' : ''}`; const btnClassName = `btn ${className} ${selected ? 'active' : ''}`;
const button = ( const button = (
<button className={btnClassName} onClick={handleChange}> <button className={btnClassName} onClick={onClick}>
<span>{children}</span> <span>{children}</span>
</button> </button>
); );

View File

@ -2,7 +2,6 @@ import React from 'react';
import { shallow } from 'enzyme'; import { shallow } from 'enzyme';
import { SideMenu } from './SideMenu'; import { SideMenu } from './SideMenu';
import appEvents from '../../app_events'; import appEvents from '../../app_events';
import { contextSrv } from 'app/core/services/context_srv';
jest.mock('../../app_events', () => ({ jest.mock('../../app_events', () => ({
emit: jest.fn(), emit: jest.fn(),
@ -26,7 +25,6 @@ jest.mock('app/core/services/context_srv', () => ({
isGrafanaAdmin: false, isGrafanaAdmin: false,
isEditor: false, isEditor: false,
hasEditPermissionFolders: false, hasEditPermissionFolders: false,
toggleSideMenu: jest.fn(),
}, },
})); }));
@ -54,20 +52,6 @@ describe('Render', () => {
}); });
describe('Functions', () => { 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', () => { describe('toggle side menu on mobile', () => {
const wrapper = setup(); const wrapper = setup();
const instance = wrapper.instance() as SideMenu; const instance = wrapper.instance() as SideMenu;

View File

@ -1,31 +1,21 @@
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
import appEvents from '../../app_events'; import appEvents from '../../app_events';
import { contextSrv } from 'app/core/services/context_srv';
import TopSection from './TopSection'; import TopSection from './TopSection';
import BottomSection from './BottomSection'; 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 { 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 = () => { toggleSideMenuSmallBreakpoint = () => {
appEvents.emit('toggle-sidemenu-mobile'); appEvents.emit('toggle-sidemenu-mobile');
}; };
render() { render() {
return [ 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" /> <img src="public/img/grafana_icon.svg" alt="Grafana" />
</div>, </a>,
<div className="sidemenu__logo_small_breakpoint" onClick={this.toggleSideMenuSmallBreakpoint} key="hamburger"> <div className="sidemenu__logo_small_breakpoint" onClick={this.toggleSideMenuSmallBreakpoint} key="hamburger">
<i className="fa fa-bars" /> <i className="fa fa-bars" />
<span className="sidemenu__close"> <span className="sidemenu__close">

View File

@ -2,16 +2,16 @@
exports[`Render should render component 1`] = ` exports[`Render should render component 1`] = `
Array [ Array [
<div <a
className="sidemenu__logo" className="sidemenu__logo"
href="/"
key="logo" key="logo"
onClick={[Function]}
> >
<img <img
alt="Grafana" alt="Grafana"
src="public/img/grafana_icon.svg" src="public/img/grafana_icon.svg"
/> />
</div>, </a>,
<div <div
className="sidemenu__logo_small_breakpoint" className="sidemenu__logo_small_breakpoint"
key="hamburger" key="hamburger"

View File

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

View File

@ -25,6 +25,7 @@ export class LoginCtrl {
$scope.disableLoginForm = config.disableLoginForm; $scope.disableLoginForm = config.disableLoginForm;
$scope.disableUserSignUp = config.disableUserSignUp; $scope.disableUserSignUp = config.disableUserSignUp;
$scope.loginHint = config.loginHint; $scope.loginHint = config.loginHint;
$scope.passwordHint = config.passwordHint;
$scope.loginMode = true; $scope.loginMode = true;
$scope.submitBtnText = 'Log in'; $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 dedupedRows = logs.rows.reduce((result: LogRowModel[], row: LogRowModel, index, list) => {
const rowCopy = { ...row };
const previous = result[result.length - 1]; const previous = result[result.length - 1];
if (index > 0 && isDuplicateRow(row, previous, strategy)) { if (index > 0 && isDuplicateRow(row, previous, strategy)) {
previous.duplicates++; previous.duplicates++;
} else { } else {
row.duplicates = 0; rowCopy.duplicates = 0;
result.push(row); result.push(rowCopy);
} }
return result; return result;
}, []); }, []);

View File

@ -1,7 +1,6 @@
import config from 'app/core/config'; import config from 'app/core/config';
import _ from 'lodash'; import _ from 'lodash';
import coreModule from 'app/core/core_module'; import coreModule from 'app/core/core_module';
import store from 'app/core/store';
export class User { export class User {
isGrafanaAdmin: any; isGrafanaAdmin: any;
@ -29,13 +28,10 @@ export class ContextSrv {
isSignedIn: any; isSignedIn: any;
isGrafanaAdmin: any; isGrafanaAdmin: any;
isEditor: any; isEditor: any;
sidemenu: any;
sidemenuSmallBreakpoint = false; sidemenuSmallBreakpoint = false;
hasEditPermissionInFolders: boolean; hasEditPermissionInFolders: boolean;
constructor() { constructor() {
this.sidemenu = store.getBool('grafana.sidemenu', true);
if (!config.bootData) { if (!config.bootData) {
config.bootData = { user: {}, settings: {} }; config.bootData = { user: {}, settings: {} };
} }
@ -55,11 +51,6 @@ export class ContextSrv {
return !!(document.visibilityState === undefined || document.visibilityState === 'visible'); return !!(document.visibilityState === undefined || document.visibilityState === 'visible');
} }
toggleSideMenu() {
this.sidemenu = !this.sidemenu;
store.set('grafana.sidemenu', this.sidemenu);
}
hasAccessToExplore() { hasAccessToExplore() {
return (this.isEditor || config.viewersCanEdit) && config.exploreEnabled; 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()', () => { describe('calculateFieldStats()', () => {

View File

@ -1,7 +1,6 @@
import kbn from 'app/core/utils/kbn';
import { getFlotTickDecimals } from 'app/core/utils/ticks'; import { getFlotTickDecimals } from 'app/core/utils/ticks';
import _ from 'lodash'; import _ from 'lodash';
import { getValueFormat } from '@grafana/ui'; import { getValueFormat, stringToJsRegex } from '@grafana/ui';
function matchSeriesOverride(aliasOrRegex, seriesAlias) { function matchSeriesOverride(aliasOrRegex, seriesAlias) {
if (!aliasOrRegex) { if (!aliasOrRegex) {
@ -9,7 +8,7 @@ function matchSeriesOverride(aliasOrRegex, seriesAlias) {
} }
if (aliasOrRegex[0] === '/') { if (aliasOrRegex[0] === '/') {
const regex = kbn.stringToJsRegex(aliasOrRegex); const regex = stringToJsRegex(aliasOrRegex);
return seriesAlias.match(regex) != null; return seriesAlias.match(regex) != null;
} }

View File

@ -1,5 +1,6 @@
import _ from 'lodash'; 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 = {}; const kbn: any = {};
@ -228,13 +229,10 @@ kbn.slugifyForUrl = str => {
.replace(/ +/g, '-'); .replace(/ +/g, '-');
}; };
/** deprecated since 6.1, use grafana/ui */
kbn.stringToJsRegex = str => { kbn.stringToJsRegex = str => {
if (str[0] !== '/') { deprecationWarning('kbn.ts', 'kbn.stringToJsRegex()', '@grafana/ui');
return new RegExp('^' + str + '$'); return stringToJsRegex(str);
}
const match = str.match(new RegExp('^/(.*?)/(g?i?m?y?)$'));
return new RegExp(match[1], match[2]);
}; };
kbn.toFixed = (value, decimals) => { kbn.toFixed = (value, decimals) => {

View File

@ -16,18 +16,20 @@ const template = `
<form name="ctrl.saveForm" class="modal-content" novalidate> <form name="ctrl.saveForm" class="modal-content" novalidate>
<div class="p-t-2"> <div class="p-t-2">
<div class="gf-form"> <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> <input type="text" class="gf-form-input" ng-model="ctrl.clone.title" give-focus="true" required>
</div> </div>
<div class="gf-form"> <folder-picker initial-folder-id="ctrl.folderId"
<folder-picker initial-folder-id="ctrl.folderId"
on-change="ctrl.onFolderChange($folder)" on-change="ctrl.onFolderChange($folder)"
enter-folder-creation="ctrl.onEnterFolderCreation()" enter-folder-creation="ctrl.onEnterFolderCreation()"
exit-folder-creation="ctrl.onExitFolderCreation()" exit-folder-creation="ctrl.onExitFolderCreation()"
enable-create-new="true" enable-create-new="true"
label-class="width-7" label-class="width-8"
dashboard-id="ctrl.clone.id"> dashboard-id="ctrl.clone.id">
</folder-picker> </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>
</div> </div>
@ -44,6 +46,7 @@ export class SaveDashboardAsModalCtrl {
folderId: any; folderId: any;
dismiss: () => void; dismiss: () => void;
isValidFolderSelection = true; isValidFolderSelection = true;
copyTags: boolean;
/** @ngInject */ /** @ngInject */
constructor(private dashboardSrv) { constructor(private dashboardSrv) {
@ -55,6 +58,7 @@ export class SaveDashboardAsModalCtrl {
this.clone.editable = true; this.clone.editable = true;
this.clone.hideControls = false; this.clone.hideControls = false;
this.folderId = dashboard.meta.folderId; this.folderId = dashboard.meta.folderId;
this.copyTags = false;
// remove alerts if source dashboard is already persisted // remove alerts if source dashboard is already persisted
// do not want to create alert dupes // do not want to create alert dupes
@ -71,6 +75,10 @@ export class SaveDashboardAsModalCtrl {
} }
save() { save() {
if (!this.copyTags) {
this.clone.tags = [];
}
return this.dashboardSrv.save(this.clone, { folderId: this.folderId }).then(this.dismiss); 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 }); this.setState({ loading: LoadingState.Loading });
try { try {
const ds = await this.dataSourceSrv.get(datasource); const ds = await this.dataSourceSrv.get(datasource, scopedVars);
// TODO interpolate variables // TODO interpolate variables
const minInterval = this.props.minInterval || ds.interval; const minInterval = this.props.minInterval || ds.interval;

View File

@ -13,8 +13,19 @@ interface Props {
export class LogLabels extends PureComponent<Props> { export class LogLabels extends PureComponent<Props> {
render() { render() {
const { getRows, labels, onClickLabel, plain } = this.props; const { getRows, labels, onClickLabel, plain } = this.props;
return Object.keys(labels).map(key => ( return (
<LogLabel key={key} getRows={getRows} label={key} value={labels[key]} plain={plain} onClickLabel={onClickLabel} /> <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> </div>
)} )}
{showLocalTime && ( {showLocalTime && (
<div className="logs-row__time" title={`${row.timestamp} (${row.timeFromNow})`}> <div className="logs-row__localtime" title={`${row.timestamp} (${row.timeFromNow})`}>
{row.timeLocal} {row.timeLocal}
</div> </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 // load datasource service
this.datasourceSrv this.datasourceSrv
.get(this.panel.datasource) .get(this.panel.datasource, this.panel.scopedVars)
.then(this.updateTimeRange.bind(this)) .then(this.updateTimeRange.bind(this))
.then(this.issueQueries.bind(this)) .then(this.issueQueries.bind(this))
.then(this.handleQueryResult.bind(this)) .then(this.handleQueryResult.bind(this))

View File

@ -7,7 +7,7 @@ import config from 'app/core/config';
import { importPluginModule } from './plugin_loader'; import { importPluginModule } from './plugin_loader';
// Types // Types
import { DataSourceApi, DataSourceSelectItem } from '@grafana/ui/src/types'; import { DataSourceApi, DataSourceSelectItem, ScopedVars } from '@grafana/ui/src/types';
export class DatasourceSrv { export class DatasourceSrv {
datasources: { [name: string]: DataSourceApi }; datasources: { [name: string]: DataSourceApi };
@ -21,12 +21,18 @@ export class DatasourceSrv {
this.datasources = {}; this.datasources = {};
} }
get(name?: string): Promise<DataSourceApi> { get(name?: string, scopedVars?: ScopedVars): Promise<DataSourceApi> {
if (!name) { if (!name) {
return this.get(config.defaultDatasource); 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') { if (name === 'default') {
return this.get(config.defaultDatasource); 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 { Variable, containsVariable, assignModelProperties, variableTypes } from './variable';
import { stringToJsRegex } from '@grafana/ui';
export class DatasourceVariable implements Variable { export class DatasourceVariable implements Variable {
regex: any; regex: any;
query: string; query: string;
options: any; options: any;
current: any; current: any;
multi: boolean;
includeAll: boolean;
refresh: any; refresh: any;
skipUrlSync: boolean; skipUrlSync: boolean;
@ -18,6 +20,8 @@ export class DatasourceVariable implements Variable {
regex: '', regex: '',
options: [], options: [],
query: '', query: '',
multi: false,
includeAll: false,
refresh: 1, refresh: 1,
skipUrlSync: false, skipUrlSync: false,
}; };
@ -47,7 +51,7 @@ export class DatasourceVariable implements Variable {
if (this.regex) { if (this.regex) {
regex = this.templateSrv.replace(this.regex, null, 'regex'); regex = this.templateSrv.replace(this.regex, null, 'regex');
regex = kbn.stringToJsRegex(regex); regex = stringToJsRegex(regex);
} }
for (let i = 0; i < sources.length; i++) { for (let i = 0; i < sources.length; i++) {
@ -69,9 +73,16 @@ export class DatasourceVariable implements Variable {
} }
this.options = options; this.options = options;
if (this.includeAll) {
this.addAllOption();
}
return this.variableSrv.validateVariableSelectionState(this); return this.variableSrv.validateVariableSelectionState(this);
} }
addAllOption() {
this.options.unshift({ text: 'All', value: '$__all' });
}
dependsOn(variable) { dependsOn(variable) {
if (this.regex) { if (this.regex) {
return containsVariable(this.regex, variable.name); return containsVariable(this.regex, variable.name);
@ -84,6 +95,9 @@ export class DatasourceVariable implements Variable {
} }
getValueForUrl() { getValueForUrl() {
if (this.current.text === 'All') {
return 'All';
}
return this.current.value; return this.current.value;
} }
} }
@ -91,5 +105,6 @@ export class DatasourceVariable implements Variable {
variableTypes['datasource'] = { variableTypes['datasource'] = {
name: 'Datasource', name: 'Datasource',
ctor: DatasourceVariable, ctor: DatasourceVariable,
supportsMulti: true,
description: 'Enabled you to dynamically switch the datasource for multiple panels', description: 'Enabled you to dynamically switch the datasource for multiple panels',
}; };

View File

@ -1,6 +1,6 @@
import _ from 'lodash'; import _ from 'lodash';
import kbn from 'app/core/utils/kbn';
import { Variable, containsVariable, assignModelProperties, variableTypes } from './variable'; import { Variable, containsVariable, assignModelProperties, variableTypes } from './variable';
import { stringToJsRegex } from '@grafana/ui';
function getNoneOption() { function getNoneOption() {
return { text: 'None', value: '', isNone: true }; return { text: 'None', value: '', isNone: true };
@ -148,7 +148,7 @@ export class QueryVariable implements Variable {
options = []; options = [];
if (this.regex) { 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++) { for (i = 0; i < metricNames.length; i++) {
const item = metricNames[i]; const item = metricNames[i];

View File

@ -13,7 +13,7 @@
</div> </div>
<div class="login-form"> <div class="login-form">
<input type="password" name="password" class="gf-form-input login-form-input" required ng-model="formModel.password" id="inputPassword" <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>
<div class="login-button-group"> <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}"> <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 _ from 'lodash';
import TableModel from 'app/core/table_model'; import TableModel from 'app/core/table_model';
import { TimeSeries } from '@grafana/ui';
export class ResultTransformer { export class ResultTransformer {
constructor(private templateSrv) {} constructor(private templateSrv) {}
@ -18,10 +19,10 @@ export class ResultTransformer {
]; ];
} else if (prometheusResult && options.format === 'heatmap') { } else if (prometheusResult && options.format === 'heatmap') {
let seriesList = []; let seriesList = [];
prometheusResult.sort(sortSeriesByLabel);
for (const metricData of prometheusResult) { for (const metricData of prometheusResult) {
seriesList.push(this.transformMetricData(metricData, options, options.start, options.end)); seriesList.push(this.transformMetricData(metricData, options, options.start, options.end));
} }
seriesList.sort(sortSeriesByLabel);
seriesList = this.transformToHistogramOverTime(seriesList); seriesList = this.transformToHistogramOverTime(seriesList);
return seriesList; return seriesList;
} else if (prometheusResult) { } 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; let le1, le2;
try { try {
// fail if not integer. might happen with bad queries // fail if not integer. might happen with bad queries
le1 = parseHistogramLabel(s1.metric.le); le1 = parseHistogramLabel(s1.target);
le2 = parseHistogramLabel(s2.metric.le); le2 = parseHistogramLabel(s2.target);
} catch (err) { } catch (err) {
console.log(err); console.log(err);
return 0; return 0;

View File

@ -55,6 +55,7 @@ const panelDefaults = {
showHistogram: false, showHistogram: false,
}, },
highlightCards: true, highlightCards: true,
hideZeroBuckets: false,
}; };
const colorModes = ['opacity', 'spectrum']; const colorModes = ['opacity', 'spectrum'];
@ -97,7 +98,7 @@ const colorSchemes = [
{ name: 'YlOrRd', value: 'interpolateYlOrRd', invert: 'dark' }, { name: 'YlOrRd', value: 'interpolateYlOrRd', invert: 'dark' },
]; ];
const dsSupportHistogramSort = ['prometheus', 'elasticsearch']; const dsSupportHistogramSort = ['elasticsearch'];
export class HeatmapCtrl extends MetricsPanelCtrl { export class HeatmapCtrl extends MetricsPanelCtrl {
static templateUrl = 'module.html'; static templateUrl = 'module.html';
@ -204,7 +205,7 @@ export class HeatmapCtrl extends MetricsPanelCtrl {
yBucketSize = 1; yBucketSize = 1;
} }
const { cards, cardStats } = convertToCards(bucketsData); const { cards, cardStats } = convertToCards(bucketsData, this.panel.hideZeroBuckets);
this.data = { this.data = {
buckets: bucketsData, buckets: bucketsData,
@ -246,7 +247,7 @@ export class HeatmapCtrl extends MetricsPanelCtrl {
// Always let yBucketSize=1 in 'tsbuckets' mode // Always let yBucketSize=1 in 'tsbuckets' mode
yBucketSize = 1; yBucketSize = 1;
const { cards, cardStats } = convertToCards(bucketsData); const { cards, cardStats } = convertToCards(bucketsData, this.panel.hideZeroBuckets);
this.data = { this.data = {
buckets: bucketsData, buckets: bucketsData,

View File

@ -93,25 +93,43 @@ function parseHistogramLabel(label: string): number {
return value; 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. * Convert buckets into linear array of "cards" - objects, represented heatmap elements.
* @param {Object} buckets * @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, let min = 0,
max = 0; max = 0;
const cards = []; const cards: HeatmapCard[] = [];
_.forEach(buckets, xBucket => { _.forEach(buckets, xBucket => {
_.forEach(xBucket.buckets, yBucket => { _.forEach(xBucket.buckets, yBucket => {
const card = { const card: HeatmapCard = {
x: xBucket.x, x: xBucket.x,
y: yBucket.y, y: yBucket.y,
yBounds: yBucket.bounds, yBounds: yBucket.bounds,
values: yBucket.values, values: yBucket.values,
count: yBucket.count, count: yBucket.count,
}; };
cards.push(card); if (!hideZero || card.count !== 0) {
cards.push(card);
}
if (cards.length === 1) { if (cards.length === 1) {
min = yBucket.count; min = yBucket.count;

View File

@ -63,6 +63,10 @@
<div class="section gf-form-group"> <div class="section gf-form-group">
<h5 class="section-heading">Buckets</h5> <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"> <div class="gf-form">
<label class="gf-form-label width-8">Space</label> <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> <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 _ from 'lodash';
import moment from 'moment'; import moment from 'moment';
import kbn from 'app/core/utils/kbn'; import { getValueFormat, getColorFromHexRgbOrName, GrafanaThemeType, stringToJsRegex } from '@grafana/ui';
import { getValueFormat, getColorFromHexRgbOrName, GrafanaThemeType } from '@grafana/ui';
export class TableRenderer { export class TableRenderer {
formatters: any[]; formatters: any[];
@ -35,7 +34,7 @@ export class TableRenderer {
for (let i = 0; i < this.panel.styles.length; i++) { for (let i = 0; i < this.panel.styles.length; i++) {
const style = this.panel.styles[i]; const style = this.panel.styles[i];
const regex = kbn.stringToJsRegex(style.pattern); const regex = stringToJsRegex(style.pattern);
if (column.text.match(regex)) { if (column.text.match(regex)) {
column.style = style; 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 { ReactPanelPlugin } from '@grafana/ui';
import { PanelProps, ReactPanelPlugin } from '@grafana/ui';
export class Text2 extends PureComponent<PanelProps> { import { TextPanelEditor } from './TextPanelEditor';
constructor(props: PanelProps) { import { TextPanel } from './TextPanel';
super(props); import { TextOptions, defaults } from './types';
}
render() { export const reactPanel = new ReactPanelPlugin<TextOptions>(TextPanel);
return <h2>Text Panel!</h2>;
}
}
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--tv');
body.removeClass('view-mode--kiosk'); body.removeClass('view-mode--kiosk');
body.removeClass('view-mode--inactive'); body.removeClass('view-mode--inactive');
switch (mode) { switch (mode) {
case 'tv': { case 'tv': {
body.removeClass('sidemenu-open');
body.addClass('view-mode--tv'); body.addClass('view-mode--tv');
break; break;
} }
// 1 & true for legacy states // 1 & true for legacy states
case '1': case '1':
case true: { case true: {
body.removeClass('sidemenu-open');
body.addClass('view-mode--kiosk'); body.addClass('view-mode--kiosk');
break; break;
} }
default: {
body.toggleClass('sidemenu-open', sidemenuOpen);
}
} }
} }
@ -105,7 +100,6 @@ export function grafanaAppDirective(playlistSrv, contextSrv, $timeout, $rootScop
restrict: 'E', restrict: 'E',
controller: GrafanaCtrl, controller: GrafanaCtrl,
link: (scope, elem) => { link: (scope, elem) => {
let sidemenuOpen;
const body = $('body'); const body = $('body');
// see https://github.com/zenorocha/clipboard.js/issues/155 // see https://github.com/zenorocha/clipboard.js/issues/155
@ -113,14 +107,6 @@ export function grafanaAppDirective(playlistSrv, contextSrv, $timeout, $rootScop
$('.preloader').remove(); $('.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', () => { appEvents.on('toggle-sidemenu-mobile', () => {
body.toggleClass('sidemenu-open--xs'); body.toggleClass('sidemenu-open--xs');
}); });
@ -163,7 +149,7 @@ export function grafanaAppDirective(playlistSrv, contextSrv, $timeout, $rootScop
$('#tooltip, .tooltip').remove(); $('#tooltip, .tooltip').remove();
// check for kiosk url param // check for kiosk url param
setViewModeBodyClass(body, data.params.kiosk, sidemenuOpen); setViewModeBodyClass(body, data.params.kiosk);
// close all drops // close all drops
for (const drop of Drop.drops) { for (const drop of Drop.drops) {
@ -198,7 +184,7 @@ export function grafanaAppDirective(playlistSrv, contextSrv, $timeout, $rootScop
} }
$timeout(() => $location.search(search)); $timeout(() => $location.search(search));
setViewModeBodyClass(body, search.kiosk, sidemenuOpen); setViewModeBodyClass(body, search.kiosk);
}); });
// handle in active view state class // handle in active view state class
@ -218,7 +204,6 @@ export function grafanaAppDirective(playlistSrv, contextSrv, $timeout, $rootScop
if (new Date().getTime() - lastActivity > inActiveTimeLimit) { if (new Date().getTime() - lastActivity > inActiveTimeLimit) {
activeUser = false; activeUser = false;
body.addClass('view-mode--inactive'); body.addClass('view-mode--inactive');
body.removeClass('sidemenu-open');
} }
} }
@ -227,7 +212,6 @@ export function grafanaAppDirective(playlistSrv, contextSrv, $timeout, $rootScop
if (!activeUser) { if (!activeUser) {
activeUser = true; activeUser = true;
body.removeClass('view-mode--inactive'); body.removeClass('view-mode--inactive');
body.toggleClass('sidemenu-open', sidemenuOpen);
} }
} }

View File

@ -157,14 +157,8 @@
@include media-breakpoint-up(sm) { @include media-breakpoint-up(sm) {
.navbar { .navbar {
padding-left: 60px; padding-left: 20px;
} margin-left: 0;
.sidemenu-open {
.navbar {
padding-left: 25px;
margin-left: 0;
}
} }
.navbar-page-btn { .navbar-page-btn {

View File

@ -63,6 +63,7 @@ $column-horizontal-spacing: 10px;
font-size: $font-size-sm; font-size: $font-size-sm;
display: table; display: table;
table-layout: fixed; table-layout: fixed;
width: 100%;
} }
.logs-row { .logs-row {
@ -83,16 +84,22 @@ $column-horizontal-spacing: 10px;
.logs-row__time { .logs-row__time {
white-space: nowrap; white-space: nowrap;
width: 19em;
}
.logs-row__localtime {
white-space: nowrap;
width: 12.5em;
} }
.logs-row__labels { .logs-row__labels {
max-width: 20%; width: 20%;
line-height: 1.2; line-height: 1.2;
position: relative;
} }
.logs-row__message { .logs-row__message {
word-break: break-all; word-break: break-all;
min-width: 80%;
} }
.logs-row__match-highlight { .logs-row__match-highlight {
@ -112,6 +119,7 @@ $column-horizontal-spacing: 10px;
.logs-row__level { .logs-row__level {
position: relative; position: relative;
width: 10px;
&::after { &::after {
content: ''; content: '';
@ -165,6 +173,7 @@ $column-horizontal-spacing: 10px;
.logs-row__duplicates { .logs-row__duplicates {
text-align: right; text-align: right;
width: 4.5em;
} }
.logs-row__field-highlight { .logs-row__field-highlight {
@ -193,15 +202,20 @@ $column-horizontal-spacing: 10px;
} }
} }
.logs-labels {
display: flex;
flex-wrap: wrap;
}
.logs-label { .logs-label {
display: inline-block; display: flex;
padding: 0 2px; padding: 0 2px;
background-color: $btn-inverse-bg; background-color: $btn-inverse-bg;
border-radius: $border-radius; border-radius: $border-radius;
margin: 0 4px 2px 0; margin: 0 4px 2px 0;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
position: relative; overflow: hidden;
} }
.logs-label__icon { .logs-label__icon {
@ -211,6 +225,13 @@ $column-horizontal-spacing: 10px;
margin-left: 2px; margin-left: 2px;
} }
.logs-label__value {
display: inline-block;
max-width: 20em;
text-overflow: ellipsis;
overflow: hidden;
}
.logs-label__stats { .logs-label__stats {
position: absolute; position: absolute;
top: 1.25em; top: 1.25em;

View File

@ -16,6 +16,14 @@
.sidemenu__close { .sidemenu__close {
display: none; 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 // 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 { .sidemenu__top {
padding-top: 3rem; padding-top: 3rem;
flex-grow: 1; flex-grow: 1;
display: none;
} }
.sidemenu__bottom { .sidemenu__bottom {
padding-bottom: $spacer; padding-bottom: $spacer;
}
.sidemenu__top,
.sidemenu__bottom {
display: none; display: none;
@include media-breakpoint-up(sm) {
display: block;
}
} }
.sidemenu-item { .sidemenu-item {

View File

@ -21,10 +21,8 @@
border-radius: 3px; border-radius: 3px;
text-shadow: none; text-shadow: none;
font-size: 13px; font-size: 13px;
padding: 3px 6px 1px 6px; padding: 2px 6px 2px 6px;
border-width: 1px; border: 1px solid lighten($purple, 10%);
border-style: solid;
box-shadow: 0 0 1px rgba($white, 0.2);
.icon-tag { .icon-tag {
position: relative; position: relative;

View File

@ -29,6 +29,21 @@
.view-mode--tv { .view-mode--tv {
@extend .view-mode--inactive; @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 { .submenu-controls {
display: none; display: none;
} }

View File

@ -25,20 +25,13 @@
} }
} }
.sidemenu-open {
.explore-toolbar-header {
padding: 0;
margin-left: 0;
}
}
.explore-toolbar { .explore-toolbar {
background: inherit; background: inherit;
display: flex; display: flex;
flex-flow: row wrap; flex-flow: row wrap;
justify-content: flex-start; justify-content: flex-start;
height: auto; height: auto;
padding: 0px $dashboard-padding 0 25px; padding: 0 $dashboard-padding;
border-bottom: 1px solid #0000; border-bottom: 1px solid #0000;
transition-duration: 0.35s; transition-duration: 0.35s;
transition-timing-function: ease-in-out; transition-timing-function: ease-in-out;
@ -134,20 +127,6 @@
} }
@media only screen and (max-width: 803px) { @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 { .btn-title {
display: none; display: none;
} }
@ -161,14 +140,6 @@
} }
@media only screen and (max-width: 544px) { @media only screen and (max-width: 544px) {
.sidemenu-open {
.explore-toolbar-header-title {
.navbar-page-btn {
margin-left: $dashboard-padding;
}
}
}
.explore-toolbar-header-title { .explore-toolbar-header-title {
.navbar-page-btn { .navbar-page-btn {
margin-left: $dashboard-padding; margin-left: $dashboard-padding;

View File

@ -33,10 +33,12 @@ program
.description('Prepares @grafana/ui release (and publishes to npm on demand)') .description('Prepares @grafana/ui release (and publishes to npm on demand)')
.option('-p, --publish', 'Publish @grafana/ui to npm registry') .option('-p, --publish', 'Publish @grafana/ui to npm registry')
.option('-u, --usePackageJsonVersion', 'Use version specified in package.json') .option('-u, --usePackageJsonVersion', 'Use version specified in package.json')
.option('--createVersionCommit', 'Create and push version commit')
.action(async cmd => { .action(async cmd => {
await execTask(releaseTask)({ await execTask(releaseTask)({
publishToNpm: !!cmd.publish, publishToNpm: !!cmd.publish,
usePackageJsonVersion: !!cmd.usePackageJsonVersion, usePackageJsonVersion: !!cmd.usePackageJsonVersion,
createVersionCommit: !!cmd.createVersionCommit,
}); });
}); });

View File

@ -9,10 +9,10 @@ import { savePackage, buildTask } from './grafanaui.build';
import { TaskRunner, Task } from './task'; import { TaskRunner, Task } from './task';
type VersionBumpType = 'prerelease' | 'patch' | 'minor' | 'major'; type VersionBumpType = 'prerelease' | 'patch' | 'minor' | 'major';
interface ReleaseTaskOptions { interface ReleaseTaskOptions {
publishToNpm: boolean; publishToNpm: boolean;
usePackageJsonVersion: boolean; usePackageJsonVersion: boolean;
createVersionCommit: boolean;
} }
const promptBumpType = async () => { 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) => const bumpVersion = (version: string) =>
useSpinner<void>(`Saving version ${version} to package.json`, async () => { useSpinner<void>(`Saving version ${version} to package.json`, async () => {
changeCwdToGrafanaUi(); 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) { if (publishToNpm) {
// TODO: Ensure release branch
// When need to update this when we star keeping @grafana/ui releases in sync with core
await ensureMasterBranch(); await ensureMasterBranch();
} }
@ -145,10 +164,15 @@ const releaseTaskRunner: TaskRunner<ReleaseTaskOptions> = async ({ publishToNpm,
await bumpVersion(nextVersion); await bumpVersion(nextVersion);
} }
if (createVersionCommit) {
await prepareVersionCommitAndPush(nextVersion);
}
if (publishToNpm) { if (publishToNpm) {
await publishPackage(pkg.name, nextVersion); await publishPackage(pkg.name, nextVersion);
console.log(chalk.green(`\nVersion ${nextVersion} of ${pkg.name} succesfully released!`)); 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(); process.exit();
} else { } else {
console.log( 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) => { export const useSpinner = <T>(spinnerLabel: string, fn: FnToSpin<T>, killProcess = true) => {
return async (options: T) => { return async (options: T) => {
const spinner = new ora(spinnerLabel); const spinner = ora(spinnerLabel);
spinner.start(); spinner.start();
try { try {
await fn(options); await fn(options);

View File

@ -1801,6 +1801,13 @@
resolved "https://registry.yarnpkg.com/@types/node/-/node-8.10.40.tgz#4314888d5cd537945d73e9ce165c04cc550144a4" resolved "https://registry.yarnpkg.com/@types/node/-/node-8.10.40.tgz#4314888d5cd537945d73e9ce165c04cc550144a4"
integrity sha512-RRSjdwz63kS4u7edIwJUn8NqKLLQ6LyqF/X4+4jp38MBT3Vwetewi2N4dgJEshLbDwNgOJXNYoOwzVZUSSLhkQ== 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@*": "@types/prop-types@*":
version "15.5.8" version "15.5.8"
resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.5.8.tgz#8ae4e0ea205fe95c3901a5a1df7f66495e3a56ce" 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" resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.8.tgz#6844890aab9c635af868ad5fecc62e8acbba3ea4"
integrity sha512-6i0HVbUfcKaTv+EG8ZTr75az7GFXcLYk9UyLEg7Notv/Ma+z/UG3TCoz6GiNeOrn1E/e63I0X/Hpw18jHOTUnA== 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: parallel-transform@^1.1.0:
version "1.1.0" version "1.1.0"
resolved "https://registry.yarnpkg.com/parallel-transform/-/parallel-transform-1.1.0.tgz#d410f065b05da23081fcd10f28854c29bda33b06" resolved "https://registry.yarnpkg.com/parallel-transform/-/parallel-transform-1.1.0.tgz#d410f065b05da23081fcd10f28854c29bda33b06"