diff --git a/lerna.json b/lerna.json new file mode 100644 index 00000000000..83a941b5539 --- /dev/null +++ b/lerna.json @@ -0,0 +1,6 @@ +{ + "npmClient": "yarn", + "useWorkspaces": true, + "packages": ["packages/*"], + "version": "6.3.0-alpha.36" +} diff --git a/package.json b/package.json index 0f196c33afd..569bbfd6096 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ "husky": "1.3.1", "jest": "24.8.0", "jest-date-mock": "1.0.7", + "lerna": "^3.15.0", "lint-staged": "8.1.5", "load-grunt-tasks": "3.5.2", "mini-css-extract-plugin": "0.5.0", @@ -143,13 +144,11 @@ "storybook:build": "cd packages/grafana-ui && yarn storybook:build", "prettier:check": "prettier --list-different \"**/*.{ts,tsx,scss}\"", "prettier:write": "prettier --list-different \"**/*.{ts,tsx,scss}\" --write", - "gui:tslint": "tslint -c ./packages/grafana-ui/tslint.json --project ./packages/grafana-ui/tsconfig.json", - "gui:build": "grafana-toolkit gui:build", - "gui:releasePrepare": "grafana-toolkit gui:release", - "gui:publish": "cd packages/grafana-ui/dist && npm publish --access public", - "gui:release": "grafana-toolkit gui:release -p --createVersionCommit", "precommit": "grafana-toolkit precommit", - "themes:generate": "ts-node --project ./scripts/cli/tsconfig.json ./scripts/cli/generateSassVariableFiles.ts" + "themes:generate": "ts-node --project ./scripts/cli/tsconfig.json ./scripts/cli/generateSassVariableFiles.ts", + "packages:prepare": "lerna run clean && npm run test && lerna version --tag-version-prefix=\"packages@\" -m \"Packages: publish %s\" --no-push", + "packages:build": "lerna run clean && lerna run build", + "packages:publish": "lerna publish from-package --contents dist --tag-version-prefix=\"packages@\" --dist-tag next" }, "husky": { "hooks": { diff --git a/packages/README.md b/packages/README.md new file mode 100644 index 00000000000..992a72c4f51 --- /dev/null +++ b/packages/README.md @@ -0,0 +1,15 @@ +## Grafana frontend packages + +## Releasing new version +We use [Lerna](https://github.com/lerna/lerna) for packages versioning and releases + +### Manual release +1. Run `packages:prepare` script from root directory. This will perform cleanup, run all tests and bump version for all packages. Also, it will create `@packages@[version]` tag and version bump commit with `Packages: publish [version]` message. +2. Run `packages:build` script that will prepare distribution packages. +3. Run `packages:publish` to publish new versions + - add `--dist-tag next` to publish under `next` tag +4. Push version commit + +### Building individual packages +To build induvidual packages run `grafana-toolkit package:build --scope=` + diff --git a/packages/grafana-data/CHANGELOG.md b/packages/grafana-data/CHANGELOG.md new file mode 100644 index 00000000000..556d4241a68 --- /dev/null +++ b/packages/grafana-data/CHANGELOG.md @@ -0,0 +1,3 @@ +# (2019-07-08) +First public release + diff --git a/packages/grafana-data/package.json b/packages/grafana-data/package.json index 86f13dce934..34d6509bfde 100644 --- a/packages/grafana-data/package.json +++ b/packages/grafana-data/package.json @@ -1,6 +1,6 @@ { "name": "@grafana/data", - "version": "6.3.0-alpha.0", + "version": "6.3.0-alpha.36", "description": "Grafana Data Library", "keywords": [ "typescript" @@ -10,11 +10,12 @@ "tslint": "tslint -c tslint.json --project tsconfig.json", "typecheck": "tsc --noEmit", "clean": "rimraf ./dist ./compiled", - "build": "rollup -c rollup.config.ts" + "bundle": "rollup -c rollup.config.ts", + "build": "grafana-toolkit package:build --scope=data", + "postpublish": "npm run clean" }, "author": "Grafana Labs", "license": "Apache-2.0", - "dependencies": {}, "devDependencies": { "@types/jest": "23.3.14", "@types/jquery": "1.10.35", diff --git a/packages/grafana-data/src/types/data.ts b/packages/grafana-data/src/types/data.ts index d7a39d1db21..9d05768b679 100644 --- a/packages/grafana-data/src/types/data.ts +++ b/packages/grafana-data/src/types/data.ts @@ -97,6 +97,9 @@ export interface AnnotationEvent { dashboardId?: number; panelId?: number; userId?: number; + login?: string; + email?: string; + avatarUrl?: string; time?: number; timeEnd?: number; isRegion?: boolean; diff --git a/packages/grafana-data/src/utils/fieldReducer.test.ts b/packages/grafana-data/src/utils/fieldReducer.test.ts index abd29307ddf..7e4e205cb22 100644 --- a/packages/grafana-data/src/utils/fieldReducer.test.ts +++ b/packages/grafana-data/src/utils/fieldReducer.test.ts @@ -91,4 +91,58 @@ describe('Stats Calculators', () => { expect(stats.step).toEqual(100); expect(stats.delta).toEqual(300); }); + + it('consistent results for first/last value with null', () => { + const info = [ + { + rows: [[null], [200], [null]], // first/last value is null + result: 200, + }, + { + rows: [[null], [null], [null]], // All null + result: undefined, + }, + { + rows: [], // Empty row + result: undefined, + }, + ]; + const fields = [{ name: 'A' }]; + + const stats = reduceField({ + series: { rows: info[0].rows, fields }, + fieldIndex: 0, + reducers: [ReducerID.first, ReducerID.last, ReducerID.firstNotNull, ReducerID.lastNotNull], // uses standard path + }); + expect(stats[ReducerID.first]).toEqual(null); + expect(stats[ReducerID.last]).toEqual(null); + expect(stats[ReducerID.firstNotNull]).toEqual(200); + expect(stats[ReducerID.lastNotNull]).toEqual(200); + + const reducers = [ReducerID.lastNotNull, ReducerID.firstNotNull]; + for (const input of info) { + for (const reducer of reducers) { + const v1 = reduceField({ + series: { rows: input.rows, fields }, + fieldIndex: 0, + reducers: [reducer, ReducerID.mean], // uses standard path + })[reducer]; + + const v2 = reduceField({ + series: { rows: input.rows, fields }, + fieldIndex: 0, + reducers: [reducer], // uses optimized path + })[reducer]; + + if (v1 !== v2 || v1 !== input.result) { + const msg = + `Invalid ${reducer} result for: ` + + input.rows.join(', ') + + ` Expected: ${input.result}` + // configured + ` Recieved: Multiple: ${v1}, Single: ${v2}`; + expect(msg).toEqual(null); + } + } + } + }); }); diff --git a/packages/grafana-data/src/utils/fieldReducer.ts b/packages/grafana-data/src/utils/fieldReducer.ts index dc0ef596e25..f0b9b789208 100644 --- a/packages/grafana-data/src/utils/fieldReducer.ts +++ b/packages/grafana-data/src/utils/fieldReducer.ts @@ -17,6 +17,9 @@ export enum ReducerID { delta = 'delta', step = 'step', + firstNotNull = 'firstNotNull', + lastNotNull = 'lastNotNull', + changeCount = 'changeCount', distinctCount = 'distinctCount', @@ -132,14 +135,28 @@ function getById(id: string): FieldReducerInfo | undefined { if (!hasBuiltIndex) { [ { - id: ReducerID.last, - name: 'Last', - description: 'Last Value (current)', + id: ReducerID.lastNotNull, + name: 'Last (not null)', + description: 'Last non-null value', standard: true, alias: 'current', + reduce: calculateLastNotNull, + }, + { + id: ReducerID.last, + name: 'Last', + description: 'Last Value', + standard: true, reduce: calculateLast, }, { id: ReducerID.first, name: 'First', description: 'First Value', standard: true, reduce: calculateFirst }, + { + id: ReducerID.firstNotNull, + name: 'First (not null)', + description: 'First non-null value', + standard: true, + reduce: calculateFirstNotNull, + }, { id: ReducerID.min, name: 'Min', description: 'Minimum Value', standard: true }, { id: ReducerID.max, name: 'Max', description: 'Maximum Value', standard: true }, { id: ReducerID.mean, name: 'Mean', description: 'Average Value', standard: true, alias: 'avg' }, @@ -231,6 +248,8 @@ function doStandardCalcs(data: DataFrame, fieldIndex: number, ignoreNulls: boole mean: null, last: null, first: null, + lastNotNull: undefined, + firstNotNull: undefined, count: 0, nonNullCount: 0, allIsNull: true, @@ -246,6 +265,10 @@ function doStandardCalcs(data: DataFrame, fieldIndex: number, ignoreNulls: boole for (let i = 0; i < data.rows.length; i++) { let currentValue = data.rows[i][fieldIndex]; + if (i === 0) { + calcs.first = currentValue; + } + calcs.last = currentValue; if (currentValue === null) { if (ignoreNulls) { @@ -257,9 +280,9 @@ function doStandardCalcs(data: DataFrame, fieldIndex: number, ignoreNulls: boole } if (currentValue !== null) { - const isFirst = calcs.first === null; + const isFirst = calcs.firstNotNull === undefined; if (isFirst) { - calcs.first = currentValue; + calcs.firstNotNull = currentValue; } if (isNumber(currentValue)) { @@ -268,12 +291,12 @@ function doStandardCalcs(data: DataFrame, fieldIndex: number, ignoreNulls: boole calcs.nonNullCount++; if (!isFirst) { - const step = currentValue - calcs.last!; + const step = currentValue - calcs.lastNotNull!; if (calcs.step > step) { calcs.step = step; // the minimum interval } - if (calcs.last! > currentValue) { + if (calcs.lastNotNull! > currentValue) { // counter reset calcs.previousDeltaUp = false; if (i === data.rows.length - 1) { @@ -307,7 +330,7 @@ function doStandardCalcs(data: DataFrame, fieldIndex: number, ignoreNulls: boole calcs.allIsZero = false; } - calcs.last = currentValue; + calcs.lastNotNull = currentValue; } } @@ -331,10 +354,8 @@ function doStandardCalcs(data: DataFrame, fieldIndex: number, ignoreNulls: boole calcs.range = calcs.max - calcs.min; } - if (calcs.first !== null && calcs.last !== null) { - if (isNumber(calcs.first) && isNumber(calcs.last)) { - calcs.diff = calcs.last - calcs.first; - } + if (isNumber(calcs.firstNotNull) && isNumber(calcs.lastNotNull)) { + calcs.diff = calcs.lastNotNull - calcs.firstNotNull; } return calcs; @@ -344,10 +365,41 @@ function calculateFirst(data: DataFrame, fieldIndex: number, ignoreNulls: boolea return { first: data.rows[0][fieldIndex] }; } +function calculateFirstNotNull( + data: DataFrame, + fieldIndex: number, + ignoreNulls: boolean, + nullAsZero: boolean +): FieldCalcs { + for (let idx = 0; idx < data.rows.length; idx++) { + const v = data.rows[idx][fieldIndex]; + if (v != null) { + return { firstNotNull: v }; + } + } + return { firstNotNull: undefined }; +} + function calculateLast(data: DataFrame, fieldIndex: number, ignoreNulls: boolean, nullAsZero: boolean): FieldCalcs { return { last: data.rows[data.rows.length - 1][fieldIndex] }; } +function calculateLastNotNull( + data: DataFrame, + fieldIndex: number, + ignoreNulls: boolean, + nullAsZero: boolean +): FieldCalcs { + let idx = data.rows.length - 1; + while (idx >= 0) { + const v = data.rows[idx--][fieldIndex]; + if (v != null) { + return { lastNotNull: v }; + } + } + return { lastNotNull: undefined }; +} + function calculateChangeCount( data: DataFrame, fieldIndex: number, diff --git a/packages/grafana-runtime/CHANGELOG.md b/packages/grafana-runtime/CHANGELOG.md new file mode 100644 index 00000000000..556d4241a68 --- /dev/null +++ b/packages/grafana-runtime/CHANGELOG.md @@ -0,0 +1,3 @@ +# (2019-07-08) +First public release + diff --git a/packages/grafana-runtime/package.json b/packages/grafana-runtime/package.json index 3ce6061298e..7798b549dac 100644 --- a/packages/grafana-runtime/package.json +++ b/packages/grafana-runtime/package.json @@ -1,6 +1,6 @@ { "name": "@grafana/runtime", - "version": "6.3.0-alpha.0", + "version": "6.3.0-alpha.36", "description": "Grafana Runtime Library", "keywords": [ "grafana" @@ -10,7 +10,9 @@ "tslint": "tslint -c tslint.json --project tsconfig.json", "typecheck": "tsc --noEmit", "clean": "rimraf ./dist ./compiled", - "build": "rollup -c rollup.config.ts" + "bundle": "rollup -c rollup.config.ts", + "build": "grafana-toolkit package:build --scope=runtime", + "postpublish": "npm run clean" }, "author": "Grafana Labs", "license": "Apache-2.0", diff --git a/packages/grafana-runtime/rollup.config.ts b/packages/grafana-runtime/rollup.config.ts index a2d6da109d9..64f5ec8a3c0 100644 --- a/packages/grafana-runtime/rollup.config.ts +++ b/packages/grafana-runtime/rollup.config.ts @@ -20,7 +20,7 @@ const buildCjsPackage = ({ env }) => { globals: {}, }, ], - external: ['lodash'], // Use Lodash from grafana + external: ['lodash', '@grafana/ui', '@grafana/data'], // Use Lodash from grafana plugins: [ commonjs({ include: /node_modules/, diff --git a/packages/grafana-toolkit/package.json b/packages/grafana-toolkit/package.json index ba583537c20..304e3c6b56b 100644 --- a/packages/grafana-toolkit/package.json +++ b/packages/grafana-toolkit/package.json @@ -1,6 +1,6 @@ { "name": "@grafana/toolkit", - "version": "6.3.0-alpha.2", + "version": "6.3.0-alpha.36", "description": "Grafana Toolkit", "keywords": [ "grafana", @@ -14,7 +14,9 @@ "tslint": "tslint -c tslint.json --project tsconfig.json", "typecheck": "tsc --noEmit", "precommit": "npm run tslint & npm run typecheck", - "clean": "rimraf ./dist ./compiled" + "clean": "rimraf ./dist ./compiled", + "build": "grafana-toolkit toolkit:build", + "postpublish": "npm run clean" }, "author": "Grafana Labs", "license": "Apache-2.0", @@ -32,6 +34,7 @@ "@types/webpack": "4.4.34", "axios": "0.19.0", "babel-loader": "8.0.6", + "babel-plugin-angularjs-annotate": "0.10.0", "chalk": "^2.4.2", "commander": "^2.20.0", "concurrently": "4.1.0", @@ -47,7 +50,6 @@ "jest-coverage-badges": "^1.1.2", "lodash": "4.17.11", "mini-css-extract-plugin": "^0.7.0", - "ng-annotate-webpack-plugin": "^0.3.0", "node-sass": "^4.12.0", "optimize-css-assets-webpack-plugin": "^5.0.3", "ora": "^3.4.0", diff --git a/packages/grafana-toolkit/src/cli/index.ts b/packages/grafana-toolkit/src/cli/index.ts index c6b8357e7a8..d4da9485d34 100644 --- a/packages/grafana-toolkit/src/cli/index.ts +++ b/packages/grafana-toolkit/src/cli/index.ts @@ -3,8 +3,6 @@ import program from 'commander'; import { execTask } from './utils/execTask'; import chalk from 'chalk'; import { startTask } from './tasks/core.start'; -import { buildTask } from './tasks/grafanaui.build'; -import { releaseTask } from './tasks/grafanaui.release'; import { changelogTask } from './tasks/changelog'; import { cherryPickTask } from './tasks/cherrypick'; import { precommitTask } from './tasks/precommit'; @@ -16,6 +14,7 @@ import { searchTestDataSetupTask } from './tasks/searchTestDataSetup'; import { closeMilestoneTask } from './tasks/closeMilestone'; import { pluginDevTask } from './tasks/plugin.dev'; import { ciBuildPluginTask, ciBundlePluginTask, ciTestPluginTask, ciDeployPluginTask } from './tasks/plugin.ci'; +import { buildPackageTask } from './tasks/package.build'; export const run = (includeInternalScripts = false) => { if (includeInternalScripts) { @@ -33,24 +32,12 @@ export const run = (includeInternalScripts = false) => { }); program - .command('gui:build') - .description('Builds @grafana/ui package to packages/grafana-ui/dist') + .command('package:build') + .option('-s, --scope ', 'packages=[data|runtime|ui|toolkit]') + .description('Builds @grafana/* package to packages/grafana-*/dist') .action(async cmd => { - // @ts-ignore - await execTask(buildTask)(); - }); - - program - .command('gui:release') - .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, + await execTask(buildPackageTask)({ + scope: cmd.scope, }); }); diff --git a/packages/grafana-toolkit/src/cli/tasks/grafanaui.release.ts b/packages/grafana-toolkit/src/cli/tasks/grafanaui.release.ts deleted file mode 100644 index eb8da0da1c2..00000000000 --- a/packages/grafana-toolkit/src/cli/tasks/grafanaui.release.ts +++ /dev/null @@ -1,190 +0,0 @@ -import execa = require('execa'); -import { execTask } from '../utils/execTask'; -import { changeCwdToGrafanaUiDist, changeCwdToGrafanaUi, restoreCwd } from '../utils/cwd'; -import { ReleaseType, inc } from 'semver'; -import { prompt } from 'inquirer'; -import chalk from 'chalk'; -import { useSpinner } from '../utils/useSpinner'; -import { savePackage, buildTask, clean } 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 () => { - return prompt<{ type: VersionBumpType }>([ - { - type: 'list', - message: 'Select version bump', - name: 'type', - choices: ['prerelease', 'patch', 'minor', 'major'], - }, - ]); -}; - -const promptPrereleaseId = async (message = 'Is this a prerelease?', allowNo = true) => { - return prompt<{ id: string }>([ - { - type: 'list', - message: message, - name: 'id', - choices: allowNo ? ['no', 'alpha', 'beta'] : ['alpha', 'beta'], - }, - ]); -}; - -const promptConfirm = async (message?: string) => { - return prompt<{ confirmed: boolean }>([ - { - type: 'confirm', - message: message || 'Is that correct?', - name: 'confirmed', - default: false, - }, - ]); -}; - -// Since Grafana core depends on @grafana/ui highly, we run full check before release -const runChecksAndTests = async () => - // @ts-ignore - useSpinner(`Running checks and tests`, async () => { - try { - await execa('npm', ['run', 'test']); - } catch (e) { - console.log(e); - throw e; - } - })(); - -const bumpVersion = (version: string) => - // @ts-ignore - useSpinner(`Saving version ${version} to package.json`, async () => { - changeCwdToGrafanaUi(); - await execa('npm', ['version', version]); - changeCwdToGrafanaUiDist(); - const pkg = require(`${process.cwd()}/package.json`); - pkg.version = version; - await savePackage({ path: `${process.cwd()}/package.json`, pkg }); - })(); - -const publishPackage = (name: string, version: string) => - // @ts-ignore - useSpinner(`Publishing ${name} @ ${version} to npm registry...`, async () => { - changeCwdToGrafanaUiDist(); - await execa('npm', ['publish', '--access', 'public']); - })(); - -const ensureMasterBranch = async () => { - const currentBranch = await execa.stdout('git', ['symbolic-ref', '--short', 'HEAD']); - const status = await execa.stdout('git', ['status', '--porcelain']); - - if (currentBranch !== 'master' && status !== '') { - console.error(chalk.red.bold('You need to be on clean master branch to release @grafana/ui')); - process.exit(1); - } -}; - -const prepareVersionCommitAndPush = async (version: string) => - // @ts-ignore - useSpinner('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 = async ({ - publishToNpm, - usePackageJsonVersion, - createVersionCommit, -}) => { - changeCwdToGrafanaUi(); - // @ts-ignore - await clean(); // Clean previous build if exists - restoreCwd(); - - 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 runChecksAndTests(); - - await execTask(buildTask)({} as any); - - let releaseConfirmed = false; - let nextVersion; - changeCwdToGrafanaUiDist(); - - const pkg = require(`${process.cwd()}/package.json`); - - console.log(`Current version: ${pkg.version}`); - - do { - if (!usePackageJsonVersion) { - const { type } = await promptBumpType(); - console.log(type); - if (type === 'prerelease') { - const { id } = await promptPrereleaseId('What kind of prerelease?', false); - nextVersion = inc(pkg.version, type, id as any); - } else { - const { id } = await promptPrereleaseId(); - if (id !== 'no') { - nextVersion = inc(pkg.version, `pre${type}` as ReleaseType, id as any); - } else { - nextVersion = inc(pkg.version, type as ReleaseType); - } - } - } else { - nextVersion = pkg.version; - } - - console.log(chalk.yellowBright.bold(`You are going to release a new version of ${pkg.name}`)); - - if (usePackageJsonVersion) { - console.log(chalk.green(`Version based on package.json: `), chalk.bold.yellowBright(`${nextVersion}`)); - } else { - console.log(chalk.green(`Version bump: ${pkg.version} ->`), chalk.bold.yellowBright(`${nextVersion}`)); - } - - const { confirmed } = await promptConfirm(); - - releaseConfirmed = confirmed; - } while (!releaseConfirmed); - - if (!usePackageJsonVersion) { - await bumpVersion(nextVersion); - } - - if (createVersionCommit) { - await prepareVersionCommitAndPush(nextVersion); - } - - if (publishToNpm) { - console.log(chalk.yellowBright.bold(`\nReview dist package.json before proceeding!\n`)); - const { confirmed } = await promptConfirm('Are you ready to publish to npm?'); - - if (!confirmed) { - process.exit(); - } - - 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.`)); - - process.exit(); - } else { - console.log( - chalk.green( - `\nVersion ${nextVersion} of ${pkg.name} succesfully prepared for release. See packages/grafana-ui/dist` - ) - ); - console.log(chalk.green(`\nTo publish to npm registry run`), chalk.bold.blue(`npm run gui:publish`)); - } -}; - -export const releaseTask = new Task('@grafana/ui release', releaseTaskRunner); diff --git a/packages/grafana-toolkit/src/cli/tasks/grafanaui.build.ts b/packages/grafana-toolkit/src/cli/tasks/package.build.ts similarity index 59% rename from packages/grafana-toolkit/src/cli/tasks/grafanaui.build.ts rename to packages/grafana-toolkit/src/cli/tasks/package.build.ts index 6dbbc7b1d36..fcf1e75472d 100644 --- a/packages/grafana-toolkit/src/cli/tasks/grafanaui.build.ts +++ b/packages/grafana-toolkit/src/cli/tasks/package.build.ts @@ -1,7 +1,9 @@ import execa = require('execa'); // @ts-ignore import * as fs from 'fs'; -import { changeCwdToGrafanaUi, restoreCwd } from '../utils/cwd'; +// @ts-ignore +import * as path from 'path'; +import { changeCwdToGrafanaUi, restoreCwd, changeCwdToPackage } from '../utils/cwd'; import chalk from 'chalk'; import { useSpinner } from '../utils/useSpinner'; import { Task, TaskRunner } from './task'; @@ -15,7 +17,7 @@ export const clean = useSpinner('Cleaning', async () => await execa('npm', const compile = useSpinner('Compiling sources', () => execa('tsc', ['-p', './tsconfig.build.json'])); // @ts-ignore -const rollup = useSpinner('Bundling', () => execa('npm', ['run', 'build'])); +const rollup = useSpinner('Bundling', () => execa('npm', ['run', 'bundle'])); interface SavePackageOptions { path: string; @@ -68,19 +70,34 @@ const moveFiles = () => { })(); }; -const buildTaskRunner: TaskRunner = async () => { - cwd = changeCwdToGrafanaUi(); - distDir = `${cwd}/dist`; - const pkg = require(`${cwd}/package.json`); - console.log(chalk.yellow(`Building ${pkg.name} (package.json version: ${pkg.version})`)); +interface PackageBuildOptions { + scope: string; +} - await clean(); - await compile(); - await rollup(); - await preparePackage(pkg); - await moveFiles(); +const buildTaskRunner: TaskRunner = async ({ scope }) => { + if (!scope) { + throw new Error('Provide packages with -s, --scope '); + } - restoreCwd(); + const scopes = scope.split(',').map(s => { + return async () => { + cwd = path.resolve(__dirname, `../../../../grafana-${s}`); + // Lerna executes this in package's dir context, but for testing purposes I want to be able to run from root: + // grafana-toolkit package:build --scope= + process.chdir(cwd); + distDir = `${cwd}/dist`; + const pkg = require(`${cwd}/package.json`); + console.log(chalk.yellow(`Building ${pkg.name} (package.json version: ${pkg.version})`)); + + await clean(); + await compile(); + await rollup(); + await preparePackage(pkg); + await moveFiles(); + }; + }); + + await Promise.all(scopes.map(s => s())); }; -export const buildTask = new Task('@grafana/ui build', buildTaskRunner); +export const buildPackageTask = new Task('@grafana/ui build', buildTaskRunner); diff --git a/packages/grafana-toolkit/src/cli/tasks/plugin.ci.ts b/packages/grafana-toolkit/src/cli/tasks/plugin.ci.ts index 516714c1d05..d57cec19c4a 100644 --- a/packages/grafana-toolkit/src/cli/tasks/plugin.ci.ts +++ b/packages/grafana-toolkit/src/cli/tasks/plugin.ci.ts @@ -8,7 +8,6 @@ import { getPluginJson } from '../../config/utils/pluginValidation'; import execa = require('execa'); import path = require('path'); import fs = require('fs'); -import tmp = require('tmp'); export interface PluginCIOptions { platform?: string; @@ -124,8 +123,8 @@ const bundlePluginRunner: TaskRunner = async () => { fs.mkdirSync(pluginFolder, { recursive: true }); await execa('unzip', [zipFile, '-d', pluginFolder]); - let ex = await execa('ls', ['-Rl', pluginFolder]); - console.log('Now load docker from:', ex.stdout); + const exe = await execa('ls', ['-Rl', pluginFolder]); + console.log('Now load docker from:', exe.stdout); }; export const ciBundlePluginTask = new Task('Bundle Plugin', bundlePluginRunner); diff --git a/packages/grafana-toolkit/src/cli/tasks/toolkit.build.ts b/packages/grafana-toolkit/src/cli/tasks/toolkit.build.ts index 61fd6fcaee0..0bee4054994 100644 --- a/packages/grafana-toolkit/src/cli/tasks/toolkit.build.ts +++ b/packages/grafana-toolkit/src/cli/tasks/toolkit.build.ts @@ -102,7 +102,7 @@ const copySassFiles = () => { }; const toolkitBuildTaskRunner: TaskRunner = async () => { - cwd = changeCwdToGrafanaToolkit(); + cwd = path.resolve(__dirname, '../../../'); distDir = `${cwd}/dist`; const pkg = require(`${cwd}/package.json`); console.log(chalk.yellow(`Building ${pkg.name} (package.json version: ${pkg.version})`)); @@ -114,7 +114,6 @@ const toolkitBuildTaskRunner: TaskRunner = async () => { fs.mkdirSync('./dist/sass'); await moveFiles(); await copySassFiles(); - restoreCwd(); }; export const toolkitBuildTask = new Task('@grafana/toolkit build', toolkitBuildTaskRunner); diff --git a/packages/grafana-toolkit/src/cli/utils/cwd.ts b/packages/grafana-toolkit/src/cli/utils/cwd.ts index 48ca39e5da2..836ebd0b608 100644 --- a/packages/grafana-toolkit/src/cli/utils/cwd.ts +++ b/packages/grafana-toolkit/src/cli/utils/cwd.ts @@ -17,3 +17,15 @@ export const changeCwdToGrafanaUiDist = () => { export const restoreCwd = () => { process.chdir(cwd); }; + +type PackageId = 'ui' | 'data' | 'runtime' | 'toolkit'; + +export const changeCwdToPackage = (scope: PackageId) => { + try { + process.chdir(`${cwd}/packages/grafana-${scope}`); + } catch (e) { + throw e; + } + + return process.cwd(); +}; diff --git a/packages/grafana-toolkit/src/config/webpack.plugin.config.ts b/packages/grafana-toolkit/src/config/webpack.plugin.config.ts index 79e461b2b4a..8d1c6bde235 100644 --- a/packages/grafana-toolkit/src/config/webpack.plugin.config.ts +++ b/packages/grafana-toolkit/src/config/webpack.plugin.config.ts @@ -5,7 +5,7 @@ const ReplaceInFileWebpackPlugin = require('replace-in-file-webpack-plugin'); const TerserPlugin = require('terser-webpack-plugin'); const MiniCssExtractPlugin = require('mini-css-extract-plugin'); const OptimizeCssAssetsPlugin = require('optimize-css-assets-webpack-plugin'); -const ngAnnotatePlugin = require('ng-annotate-webpack-plugin'); + import * as webpack from 'webpack'; import { hasThemeStylesheets, getStyleLoaders, getStylesheetEntries, getFileLoaders } from './webpack/loaders'; @@ -114,7 +114,6 @@ export const getWebpackConfig: WebpackConfigurationGetter = options => { const optimization: { [key: string]: any } = {}; if (options.production) { - plugins.push(new ngAnnotatePlugin()); optimization.minimizer = [new TerserPlugin(), new OptimizeCssAssetsPlugin()]; } @@ -177,8 +176,12 @@ export const getWebpackConfig: WebpackConfigurationGetter = options => { loaders: [ { loader: 'babel-loader', - options: { presets: ['@babel/preset-env'] }, + options: { + presets: ['@babel/preset-env'], + plugins: ['angularjs-annotate'], + }, }, + 'ts-loader', ], exclude: /(node_modules)/, diff --git a/packages/grafana-ui/README.md b/packages/grafana-ui/README.md index f897127b660..bc45a78e576 100644 --- a/packages/grafana-ui/README.md +++ b/packages/grafana-ui/README.md @@ -15,37 +15,3 @@ See [package source](https://github.com/grafana/grafana/tree/master/packages/gra ## 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_. This has to be done on the master branch. The script will prepare the distribution package as well as prompt you to bump library version and publish it to the NPM registry. When the new package is published, create a PR with the bumped version in package.json. - -### 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 - -Manually update the version in `package.json` and then 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 -6. 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. diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index 030377923a5..de0cb7f3eb1 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -1,6 +1,6 @@ { "name": "@grafana/ui", - "version": "6.3.0-alpha.1", + "version": "6.3.0-alpha.36", "description": "Grafana Components Library", "keywords": [ "grafana", @@ -14,7 +14,9 @@ "storybook": "start-storybook -p 9001 -c .storybook", "storybook:build": "build-storybook -o ./dist/storybook -c .storybook", "clean": "rimraf ./dist ./compiled", - "build": "rollup -c rollup.config.ts" + "bundle": "rollup -c rollup.config.ts", + "build": "grafana-toolkit package:build --scope=ui", + "postpublish": "npm run clean" }, "author": "Grafana Labs", "license": "Apache-2.0", diff --git a/pkg/api/common_test.go b/pkg/api/common_test.go index 0764fb0bfd6..590074f7193 100644 --- a/pkg/api/common_test.go +++ b/pkg/api/common_test.go @@ -93,6 +93,26 @@ func (sc *scenarioContext) fakeReqWithParams(method, url string, queryParams map return sc } +func (sc *scenarioContext) fakeReqNoAssertions(method, url string) *scenarioContext { + sc.resp = httptest.NewRecorder() + req, _ := http.NewRequest(method, url, nil) + sc.req = req + + return sc +} + +func (sc *scenarioContext) fakeReqNoAssertionsWithCookie(method, url string, cookie http.Cookie) *scenarioContext { + sc.resp = httptest.NewRecorder() + http.SetCookie(sc.resp, &cookie) + + req, _ := http.NewRequest(method, url, nil) + req.Header = http.Header{"Cookie": sc.resp.Header()["Set-Cookie"]} + + sc.req = req + + return sc +} + type scenarioContext struct { m *macaron.Macaron context *m.ReqContext diff --git a/pkg/api/login.go b/pkg/api/login.go index 37df4613212..61a6299b935 100644 --- a/pkg/api/login.go +++ b/pkg/api/login.go @@ -21,8 +21,14 @@ const ( LoginErrorCookieName = "login_error" ) +var setIndexViewData = (*HTTPServer).setIndexViewData + +var getViewIndex = func() string { + return ViewIndex +} + func (hs *HTTPServer) LoginView(c *models.ReqContext) { - viewData, err := hs.setIndexViewData(c) + viewData, err := setIndexViewData(hs, c) if err != nil { c.Handle(500, "Failed to get settings", err) return @@ -41,8 +47,14 @@ func (hs *HTTPServer) LoginView(c *models.ReqContext) { viewData.Settings["samlEnabled"] = hs.Cfg.SAMLEnabled if loginError, ok := tryGetEncryptedCookie(c, LoginErrorCookieName); ok { + //this cookie is only set whenever an OAuth login fails + //therefore the loginError should be passed to the view data + //and the view should return immediately before attempting + //to login again via OAuth and enter to a redirect loop deleteCookie(c, LoginErrorCookieName) viewData.Settings["loginError"] = loginError + c.HTML(200, getViewIndex(), viewData) + return } if tryOAuthAutoLogin(c) { diff --git a/pkg/api/login_test.go b/pkg/api/login_test.go new file mode 100644 index 00000000000..ab28848a43d --- /dev/null +++ b/pkg/api/login_test.go @@ -0,0 +1,135 @@ +package api + +import ( + "encoding/hex" + "errors" + "github.com/grafana/grafana/pkg/api/dtos" + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/util" + "github.com/stretchr/testify/assert" + "io/ioutil" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func mockSetIndexViewData() { + setIndexViewData = func(*HTTPServer, *models.ReqContext) (*dtos.IndexViewData, error) { + data := &dtos.IndexViewData{ + User: &dtos.CurrentUser{}, + Settings: map[string]interface{}{}, + NavTree: []*dtos.NavLink{}, + } + return data, nil + } +} + +func resetSetIndexViewData() { + setIndexViewData = (*HTTPServer).setIndexViewData +} + +func mockViewIndex() { + getViewIndex = func() string { + return "index-template" + } +} + +func resetViewIndex() { + getViewIndex = func() string { + return ViewIndex + } +} + +func getBody(resp *httptest.ResponseRecorder) (string, error) { + responseData, err := ioutil.ReadAll(resp.Body) + if err != nil { + return "", err + } + return string(responseData), nil +} + +func TestLoginErrorCookieApiEndpoint(t *testing.T) { + mockSetIndexViewData() + defer resetSetIndexViewData() + + mockViewIndex() + defer resetViewIndex() + + sc := setupScenarioContext("/login") + hs := &HTTPServer{ + Cfg: setting.NewCfg(), + } + + sc.defaultHandler = Wrap(func(w http.ResponseWriter, c *models.ReqContext) { + hs.LoginView(c) + }) + + setting.OAuthService = &setting.OAuther{} + setting.OAuthService.OAuthInfos = make(map[string]*setting.OAuthInfo) + setting.LoginCookieName = "grafana_session" + setting.SecretKey = "login_testing" + + setting.OAuthService = &setting.OAuther{} + setting.OAuthService.OAuthInfos = make(map[string]*setting.OAuthInfo) + setting.OAuthService.OAuthInfos["github"] = &setting.OAuthInfo{ + ClientId: "fake", + ClientSecret: "fakefake", + Enabled: true, + AllowSignup: true, + Name: "github", + } + setting.OAuthAutoLogin = true + + oauthError := errors.New("User not a member of one of the required organizations") + encryptedError, _ := util.Encrypt([]byte(oauthError.Error()), setting.SecretKey) + cookie := http.Cookie{ + Name: LoginErrorCookieName, + MaxAge: 60, + Value: hex.EncodeToString(encryptedError), + HttpOnly: true, + Path: setting.AppSubUrl + "/", + Secure: hs.Cfg.CookieSecure, + SameSite: hs.Cfg.CookieSameSite, + } + sc.m.Get(sc.url, sc.defaultHandler) + sc.fakeReqNoAssertionsWithCookie("GET", sc.url, cookie).exec() + assert.Equal(t, sc.resp.Code, 200) + + responseString, err := getBody(sc.resp) + assert.Nil(t, err) + assert.True(t, strings.Contains(responseString, oauthError.Error())) +} + +func TestLoginOAuthRedirect(t *testing.T) { + mockSetIndexViewData() + defer resetSetIndexViewData() + + sc := setupScenarioContext("/login") + hs := &HTTPServer{ + Cfg: setting.NewCfg(), + } + + sc.defaultHandler = Wrap(func(c *models.ReqContext) { + hs.LoginView(c) + }) + + setting.OAuthService = &setting.OAuther{} + setting.OAuthService.OAuthInfos = make(map[string]*setting.OAuthInfo) + setting.OAuthService.OAuthInfos["github"] = &setting.OAuthInfo{ + ClientId: "fake", + ClientSecret: "fakefake", + Enabled: true, + AllowSignup: true, + Name: "github", + } + setting.OAuthAutoLogin = true + sc.m.Get(sc.url, sc.defaultHandler) + sc.fakeReqNoAssertions("GET", sc.url).exec() + + assert.Equal(t, sc.resp.Code, 307) + location, ok := sc.resp.Header()["Location"] + assert.True(t, ok) + assert.Equal(t, location[0], "/login/github") +} diff --git a/pkg/middleware/middleware.go b/pkg/middleware/middleware.go index 49ec9f54b2a..d4a0b2da2aa 100644 --- a/pkg/middleware/middleware.go +++ b/pkg/middleware/middleware.go @@ -182,6 +182,10 @@ func initContextWithBasicAuth(ctx *models.ReqContext, orgId int64) bool { } func initContextWithToken(authTokenService models.UserTokenService, ctx *models.ReqContext, orgID int64) bool { + if setting.LoginCookieName == "" { + return false + } + rawToken := ctx.GetCookie(setting.LoginCookieName) if rawToken == "" { return false diff --git a/pkg/services/ldap/ldap.go b/pkg/services/ldap/ldap.go index 0e4404340b5..c35f274b7e3 100644 --- a/pkg/services/ldap/ldap.go +++ b/pkg/services/ldap/ldap.go @@ -140,15 +140,19 @@ func (server *Server) Login(query *models.LoginUserQuery) ( *models.ExternalUserInfo, error, ) { var err error + var authAndBind bool - // Do we need to authenticate the "admin" user first? - // Admin user should have access for the user search in LDAP server + // Check if we can use a search user if server.shouldAuthAdmin() { if err := server.AuthAdmin(); err != nil { return nil, err } - - // Or if anyone can perform the search in LDAP? + } else if server.shouldSingleBind() { + authAndBind = true + err = server.Auth(server.singleBindDN(query.Username), query.Password) + if err != nil { + return nil, err + } } else { err := server.Connection.UnauthenticatedBind(server.Config.BindDN) if err != nil { @@ -173,15 +177,25 @@ func (server *Server) Login(query *models.LoginUserQuery) ( return nil, err } - // Authenticate user - err = server.Auth(user.AuthId, query.Password) - if err != nil { - return nil, err + if !authAndBind { + // Authenticate user + err = server.Auth(user.AuthId, query.Password) + if err != nil { + return nil, err + } } return user, nil } +func (server *Server) singleBindDN(username string) string { + return fmt.Sprintf(server.Config.BindDN, username) +} + +func (server *Server) shouldSingleBind() bool { + return strings.Contains(server.Config.BindDN, "%s") +} + // getUsersIteration is a helper function for Users() method. // It divides the users by equal parts for the anticipated requests func getUsersIteration(logins []string, fn func(int, int) error) error { diff --git a/pkg/services/ldap/ldap_login_test.go b/pkg/services/ldap/ldap_login_test.go index 1828eeb9d7b..6aee77e4863 100644 --- a/pkg/services/ldap/ldap_login_test.go +++ b/pkg/services/ldap/ldap_login_test.go @@ -198,5 +198,37 @@ func TestLDAPLogin(t *testing.T) { So(username, ShouldEqual, "test") So(password, ShouldEqual, "pwd") }) + Convey("Should bind with user if %s exists in the bind_dn", func() { + connection := &MockConnection{} + entry := ldap.Entry{ + DN: "test", + } + connection.setSearchResult(&ldap.SearchResult{Entries: []*ldap.Entry{&entry}}) + + authBindUser := "" + authBindPassword := "" + + connection.BindProvider = func(name, pass string) error { + authBindUser = name + authBindPassword = pass + return nil + } + server := &Server{ + Config: &ServerConfig{ + BindDN: "cn=%s,ou=users,dc=grafana,dc=org", + SearchBaseDNs: []string{"BaseDNHere"}, + }, + Connection: connection, + log: log.New("test-logger"), + } + + _, err := server.Login(defaultLogin) + + So(err, ShouldBeNil) + + So(authBindUser, ShouldEqual, "cn=user,ou=users,dc=grafana,dc=org") + So(authBindPassword, ShouldEqual, "pwd") + So(connection.BindCalled, ShouldBeTrue) + }) }) } diff --git a/public/app/core/components/TagFilter/TagFilter.tsx b/public/app/core/components/TagFilter/TagFilter.tsx index 89d3e8469b6..845b0838a4c 100644 --- a/public/app/core/components/TagFilter/TagFilter.tsx +++ b/public/app/core/components/TagFilter/TagFilter.tsx @@ -11,9 +11,14 @@ import { TagBadge } from './TagBadge'; import { NoOptionsMessage, IndicatorsContainer, resetSelectStyles } from '@grafana/ui'; import { escapeStringForRegex } from '../FilterInput/FilterInput'; +export interface TermCount { + term: string; + count: number; +} + export interface Props { tags: string[]; - tagOptions: () => any; + tagOptions: () => Promise; onChange: (tags: string[]) => void; } @@ -25,7 +30,7 @@ export class TagFilter extends React.Component { } onLoadOptions = (query: string) => { - return this.props.tagOptions().then((options: any[]) => { + return this.props.tagOptions().then(options => { return options.map(option => ({ value: option.term, label: option.term, diff --git a/public/app/core/specs/file_export.test.ts b/public/app/core/specs/file_export.test.ts index ab254a94f2b..49a7b9caf6d 100644 --- a/public/app/core/specs/file_export.test.ts +++ b/public/app/core/specs/file_export.test.ts @@ -28,7 +28,7 @@ describe('file_export', () => { describe('when exporting series as rows', () => { it('should export points in proper order', () => { - const text = fileExport.convertSeriesListToCsv(ctx.seriesList, ctx.timeFormat); + const text = fileExport.convertSeriesListToCsv(ctx.seriesList, { dateTimeFormat: ctx.timeFormat }); const expectedText = '"Series";"Time";"Value"\r\n' + '"series_1";"1500026100";1\r\n' + @@ -48,7 +48,7 @@ describe('file_export', () => { describe('when exporting series as columns', () => { it('should export points in proper order', () => { - const text = fileExport.convertSeriesListToCsvColumns(ctx.seriesList, ctx.timeFormat); + const text = fileExport.convertSeriesListToCsvColumns(ctx.seriesList, { dateTimeFormat: ctx.timeFormat }); const expectedText = '"Time";"series_1";"series_2"\r\n' + '"1500026100";1;11\r\n' + @@ -65,7 +65,7 @@ describe('file_export', () => { const expectedSeries1DataPoints = ctx.seriesList[0].datapoints.slice(); const expectedSeries2DataPoints = ctx.seriesList[1].datapoints.slice(); - fileExport.convertSeriesListToCsvColumns(ctx.seriesList, ctx.timeFormat); + fileExport.convertSeriesListToCsvColumns(ctx.seriesList, { dateTimeFormat: ctx.timeFormat }); expect(expectedSeries1DataPoints).toEqual(ctx.seriesList[0].datapoints); expect(expectedSeries2DataPoints).toEqual(ctx.seriesList[1].datapoints); diff --git a/public/app/core/utils/file_export.ts b/public/app/core/utils/file_export.ts index 212c619e7ea..14fc10cfbb6 100644 --- a/public/app/core/utils/file_export.ts +++ b/public/app/core/utils/file_export.ts @@ -1,7 +1,7 @@ import { isBoolean, isNumber, sortedUniq, sortedIndexOf, unescape as htmlUnescaped } from 'lodash'; import { saveAs } from 'file-saver'; import { isNullOrUndefined } from 'util'; -import { dateTime } from '@grafana/data'; +import { dateTime, TimeZone } from '@grafana/data'; const DEFAULT_DATETIME_FORMAT = 'YYYY-MM-DDTHH:mm:ssZ'; const POINT_TIME_INDEX = 1; @@ -12,7 +12,19 @@ const END_ROW = '\r\n'; const QUOTE = '"'; const EXPORT_FILENAME = 'grafana_data_export.csv'; -function csvEscaped(text) { +interface SeriesListToCsvColumnsOptions { + dateTimeFormat: string; + excel: boolean; + timezone: TimeZone; +} + +const defaultOptions: SeriesListToCsvColumnsOptions = { + dateTimeFormat: DEFAULT_DATETIME_FORMAT, + excel: false, + timezone: '', +}; + +function csvEscaped(text: string) { if (!text) { return text; } @@ -25,13 +37,13 @@ function csvEscaped(text) { } const domParser = new DOMParser(); -function htmlDecoded(text) { +function htmlDecoded(text: string) { if (!text) { return text; } const regexp = /&[^;]+;/g; - function htmlDecoded(value) { + function htmlDecoded(value: string) { const parsedDom = domParser.parseFromString(value, 'text/html'); return parsedDom.body.textContent; } @@ -58,14 +70,19 @@ function formatRow(row, addEndRowDelimiter = true) { return addEndRowDelimiter ? text + END_ROW : text; } -export function convertSeriesListToCsv(seriesList, dateTimeFormat = DEFAULT_DATETIME_FORMAT, excel = false) { +export function convertSeriesListToCsv(seriesList, options: Partial) { + const { dateTimeFormat, excel, timezone } = { ...defaultOptions, ...options }; let text = formatSpecialHeader(excel) + formatRow(['Series', 'Time', 'Value']); for (let seriesIndex = 0; seriesIndex < seriesList.length; seriesIndex += 1) { for (let i = 0; i < seriesList[seriesIndex].datapoints.length; i += 1) { text += formatRow( [ seriesList[seriesIndex].alias, - dateTime(seriesList[seriesIndex].datapoints[i][POINT_TIME_INDEX]).format(dateTimeFormat), + timezone === 'utc' + ? dateTime(seriesList[seriesIndex].datapoints[i][POINT_TIME_INDEX]) + .utc() + .format(dateTimeFormat) + : dateTime(seriesList[seriesIndex].datapoints[i][POINT_TIME_INDEX]).format(dateTimeFormat), seriesList[seriesIndex].datapoints[i][POINT_VALUE_INDEX], ], i < seriesList[seriesIndex].datapoints.length - 1 || seriesIndex < seriesList.length - 1 @@ -75,12 +92,13 @@ export function convertSeriesListToCsv(seriesList, dateTimeFormat = DEFAULT_DATE return text; } -export function exportSeriesListToCsv(seriesList, dateTimeFormat = DEFAULT_DATETIME_FORMAT, excel = false) { - const text = convertSeriesListToCsv(seriesList, dateTimeFormat, excel); +export function exportSeriesListToCsv(seriesList, options: Partial) { + const text = convertSeriesListToCsv(seriesList, options); saveSaveBlob(text, EXPORT_FILENAME); } -export function convertSeriesListToCsvColumns(seriesList, dateTimeFormat = DEFAULT_DATETIME_FORMAT, excel = false) { +export function convertSeriesListToCsvColumns(seriesList, options: Partial) { + const { dateTimeFormat, excel, timezone } = { ...defaultOptions, ...options }; // add header let text = formatSpecialHeader(excel) + @@ -96,7 +114,13 @@ export function convertSeriesListToCsvColumns(seriesList, dateTimeFormat = DEFAU // make text for (let i = 0; i < extendedDatapointsList[0].length; i += 1) { - const timestamp = dateTime(extendedDatapointsList[0][i][POINT_TIME_INDEX]).format(dateTimeFormat); + const timestamp = + timezone === 'utc' + ? dateTime(extendedDatapointsList[0][i][POINT_TIME_INDEX]) + .utc() + .format(dateTimeFormat) + : dateTime(extendedDatapointsList[0][i][POINT_TIME_INDEX]).format(dateTimeFormat); + text += formatRow( [timestamp].concat( extendedDatapointsList.map(datapoints => { @@ -143,8 +167,8 @@ function mergeSeriesByTime(seriesList) { return result; } -export function exportSeriesListToCsvColumns(seriesList, dateTimeFormat = DEFAULT_DATETIME_FORMAT, excel = false) { - const text = convertSeriesListToCsvColumns(seriesList, dateTimeFormat, excel); +export function exportSeriesListToCsvColumns(seriesList, options: Partial) { + const text = convertSeriesListToCsvColumns(seriesList, options); saveSaveBlob(text, EXPORT_FILENAME); } diff --git a/public/app/features/dashboard/components/ExportDataModal/ExportDataModalCtrl.ts b/public/app/features/dashboard/components/ExportDataModal/ExportDataModalCtrl.ts index f87daa94ee7..023b60ac074 100644 --- a/public/app/features/dashboard/components/ExportDataModal/ExportDataModalCtrl.ts +++ b/public/app/features/dashboard/components/ExportDataModal/ExportDataModalCtrl.ts @@ -1,6 +1,7 @@ import angular from 'angular'; import * as fileExport from 'app/core/utils/file_export'; import appEvents from 'app/core/app_events'; +import { DashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; export class ExportDataModalCtrl { private data: any[]; @@ -9,14 +10,23 @@ export class ExportDataModalCtrl { dateTimeFormat = 'YYYY-MM-DDTHH:mm:ssZ'; excel = false; + /** @ngInject */ + constructor(private dashboardSrv: DashboardSrv) {} + export() { + const timezone = this.dashboardSrv.getCurrent().timezone; + const options = { + excel: this.excel, + dateTimeFormat: this.dateTimeFormat, + timezone, + }; if (this.panel === 'table') { fileExport.exportTableDataToCsv(this.data, this.excel); } else { if (this.asRows) { - fileExport.exportSeriesListToCsv(this.data, this.dateTimeFormat, this.excel); + fileExport.exportSeriesListToCsv(this.data, options); } else { - fileExport.exportSeriesListToCsvColumns(this.data, this.dateTimeFormat, this.excel); + fileExport.exportSeriesListToCsvColumns(this.data, options); } } diff --git a/public/app/features/plugins/built_in_plugins.ts b/public/app/features/plugins/built_in_plugins.ts index 4429085fcff..913f8e9a36f 100644 --- a/public/app/features/plugins/built_in_plugins.ts +++ b/public/app/features/plugins/built_in_plugins.ts @@ -22,6 +22,7 @@ import * as graphPanel from 'app/plugins/panel/graph/module'; import * as dashListPanel from 'app/plugins/panel/dashlist/module'; import * as pluginsListPanel from 'app/plugins/panel/pluginlist/module'; import * as alertListPanel from 'app/plugins/panel/alertlist/module'; +import * as annoListPanel from 'app/plugins/panel/annolist/module'; import * as heatmapPanel from 'app/plugins/panel/heatmap/module'; import * as tablePanel from 'app/plugins/panel/table/module'; import * as table2Panel from 'app/plugins/panel/table2/module'; @@ -59,6 +60,7 @@ const builtInPlugins = { 'app/plugins/panel/dashlist/module': dashListPanel, 'app/plugins/panel/pluginlist/module': pluginsListPanel, 'app/plugins/panel/alertlist/module': alertListPanel, + 'app/plugins/panel/annolist/module': annoListPanel, 'app/plugins/panel/heatmap/module': heatmapPanel, 'app/plugins/panel/table/module': tablePanel, 'app/plugins/panel/table2/module': table2Panel, diff --git a/public/app/partials/login.html b/public/app/partials/login.html index 8acc0527fdb..78ff178c151 100644 --- a/public/app/partials/login.html +++ b/public/app/partials/login.html @@ -45,6 +45,10 @@
+