Merge remote-tracking branch 'grafana/master' into alpha-react-virtualized-table

* grafana/master: (24 commits)
  docs: Fix indentation level for OAuth2 config
  docs: update CONTRIBUTING.md
  @grafana/ui - release docs v1 (#15835)
  Minor refactoring of copy tags when saving feature, #15446
  Simple implementation for preserve tags, closes #11627
  Updated prettierignore
  Refactoring of PR #14772
  fix typo in pr template
  add nil/length check when delete old login attempts
  Minor refactoring of new react text panel
  fix allow anonymous server bind for ldap search
  use pure component
  use replaceVariables
  Catch bad regex exception at controller level
  11780: invalid reg value can cause unexpected behaviour
  update
  Add string quote func
  Remove option used to control within browser
  Add pic into actionCard message
  Add new option to set where to open the message url
  ...
This commit is contained in:
ryan 2019-03-08 20:52:30 -08:00
commit 343e49decb
20 changed files with 400 additions and 53 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.
If the pull request needs changes before its merged the new commits should be rebased into one commit before its merged.

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

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

@ -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"
)
func init() {
alerting.RegisterNotifier(&alerting.NotifierPlugin{
Type: "dingding",
Name: "DingDing",
Description: "Sends HTTP POST request to DingDing",
Factory: NewDingDingNotifier,
OptionsTemplate: `
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-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 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"}
}
msgType := model.Settings.Get("msgType").MustString(DefaultDingdingMsgType)
return &DingDingNotifier{
NotifierBase: NewNotifierBase(model),
MsgType: msgType,
Url: url,
log: log.New("alerting.notifier.dingding"),
}, nil
@ -40,8 +54,9 @@ func NewDingDingNotifier(model *m.AlertNotification) (alerting.Notifier, error)
type DingDingNotifier struct {
NotifierBase
Url string
log log.Logger
MsgType string
Url string
log log.Logger
}
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)
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,15 +86,39 @@ func (this *DingDingNotifier) Notify(evalContext *alerting.EvalContext) error {
message = title
}
bodyJSON, err := simplejson.NewJson([]byte(`{
"msgtype": "link",
"link": {
"text": "` + message + `",
"title": "` + title + `",
"picUrl": "` + picUrl + `",
"messageUrl": "` + messageUrl + `"
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 + `",
"title": "` + title + `",
"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

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

@ -0,0 +1,15 @@
import kbn from '../utils/kbn';
describe('stringToJsRegex', () => {
it('should parse the valid regex value', () => {
const output = kbn.stringToJsRegex('/validRegexp/');
expect(output).toBeInstanceOf(RegExp);
});
it('should throw error on invalid regex value', () => {
const input = '/etc/hostname';
expect(() => {
kbn.stringToJsRegex(input);
}).toThrow();
});
});

View File

@ -234,6 +234,11 @@ kbn.stringToJsRegex = 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

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

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

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

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