;
+
+// Use Grafana Dark theme by default
+export const ThemeContext = React.createContext(getTheme(GrafanaThemeType.Dark));
+
+export const withTheme = (Component: React.ComponentType
) => {
+ const WithTheme: React.FunctionComponent> = props => {
+ // @ts-ignore
+ return {theme => } ;
+ };
+
+ WithTheme.displayName = `WithTheme(${Component.displayName})`;
+
+ return WithTheme;
+};
diff --git a/packages/grafana-ui/src/themes/dark.ts b/packages/grafana-ui/src/themes/dark.ts
new file mode 100644
index 00000000000..deae022f63a
--- /dev/null
+++ b/packages/grafana-ui/src/themes/dark.ts
@@ -0,0 +1,69 @@
+import tinycolor from 'tinycolor2';
+import defaultTheme from './default';
+import { GrafanaTheme, GrafanaThemeType } from '../types/theme';
+
+const basicColors = {
+ black: '#000000',
+ white: '#ffffff',
+ dark1: '#141414',
+ dark2: '#1f1f20',
+ dark3: '#262628',
+ dark4: '#333333',
+ dark5: '#444444',
+ gray1: '#555555',
+ gray2: '#8e8e8e',
+ gray3: '#b3b3b3',
+ gray4: '#d8d9da',
+ gray5: '#ececec',
+ gray6: '#f4f5f8',
+ gray7: '#fbfbfb',
+ grayBlue: '#212327',
+ blue: '#33b5e5',
+ blueDark: '#005f81',
+ blueLight: '#00a8e6', // not used in dark theme
+ green: '#299c46',
+ red: '#d44a3a',
+ yellow: '#ecbb13',
+ pink: '#ff4444',
+ purple: '#9933cc',
+ variable: '#32d1df',
+ orange: '#eb7b18',
+};
+
+const darkTheme: GrafanaTheme = {
+ ...defaultTheme,
+ type: GrafanaThemeType.Dark,
+ name: 'Grafana Dark',
+ colors: {
+ ...basicColors,
+ inputBlack: '#09090b',
+ queryRed: '#e24d42',
+ queryGreen: '#74e680',
+ queryPurple: '#fe85fc',
+ queryKeyword: '#66d9ef',
+ queryOrange: 'eb7b18',
+ online: '#10a345',
+ warn: '#f79520',
+ critical: '#ed2e18',
+ bodyBg: '#171819',
+ pageBg: '#161719',
+ bodyColor: basicColors.gray4,
+ textColor: basicColors.gray4,
+ textColorStrong: basicColors.white,
+ textColorWeak: basicColors.gray2,
+ textColorEmphasis: basicColors.gray5,
+ textColorFaint: basicColors.dark5,
+ linkColor: new tinycolor(basicColors.white).darken(11).toString(),
+ linkColorDisabled: new tinycolor(basicColors.white).darken(11).toString(),
+ linkColorHover: basicColors.white,
+ linkColorExternal: basicColors.blue,
+ headingColor: new tinycolor(basicColors.white).darken(11).toString(),
+ },
+ background: {
+ dropdown: basicColors.dark3,
+ scrollbar: '#aeb5df',
+ scrollbar2: '#3a3a3a',
+ },
+};
+
+export default darkTheme;
diff --git a/packages/grafana-ui/src/themes/default.ts b/packages/grafana-ui/src/themes/default.ts
new file mode 100644
index 00000000000..bf318f526e7
--- /dev/null
+++ b/packages/grafana-ui/src/themes/default.ts
@@ -0,0 +1,62 @@
+
+
+const theme = {
+ name: 'Grafana Default',
+ typography: {
+ fontFamily: {
+ sansSerif: "'Roboto', Helvetica, Arial, sans-serif;",
+ serif: "Georgia, 'Times New Roman', Times, serif;",
+ monospace: "Menlo, Monaco, Consolas, 'Courier New', monospace;"
+ },
+ size: {
+ base: '13px',
+ xs: '10px',
+ s: '12px',
+ m: '14px',
+ l: '18px',
+ },
+ heading: {
+ h1: '2rem',
+ h2: '1.75rem',
+ h3: '1.5rem',
+ h4: '1.3rem',
+ h5: '1.2rem',
+ h6: '1rem',
+ },
+ weight: {
+ light: 300,
+ normal: 400,
+ semibold: 500,
+ },
+ lineHeight: {
+ xs: 1,
+ s: 1.1,
+ m: 4/3,
+ l: 1.5
+ }
+ },
+ brakpoints: {
+ xs: '0',
+ s: '544px',
+ m: '768px',
+ l: '992px',
+ xl: '1200px'
+ },
+ spacing: {
+ xs: '0',
+ s: '0.2rem',
+ m: '1rem',
+ l: '1.5rem',
+ xl: '3rem',
+ gutter: '30px',
+ },
+ border: {
+ radius: {
+ xs: '2px',
+ s: '3px',
+ m: '5px',
+ }
+ }
+};
+
+export default theme;
diff --git a/packages/grafana-ui/src/themes/index.ts b/packages/grafana-ui/src/themes/index.ts
new file mode 100644
index 00000000000..c0d9a4f2d32
--- /dev/null
+++ b/packages/grafana-ui/src/themes/index.ts
@@ -0,0 +1,14 @@
+import darkTheme from './dark';
+import lightTheme from './light';
+import { GrafanaTheme } from '../types/theme';
+
+let themeMock: ((name?: string) => GrafanaTheme) | null;
+
+export let getTheme = (name?: string) => (themeMock && themeMock(name)) || (name === 'light' ? lightTheme : darkTheme);
+
+export const mockTheme = (mock: (name: string) => GrafanaTheme) => {
+ themeMock = mock;
+ return () => {
+ themeMock = null;
+ };
+};
diff --git a/packages/grafana-ui/src/themes/light.ts b/packages/grafana-ui/src/themes/light.ts
new file mode 100644
index 00000000000..fd1f1d05b95
--- /dev/null
+++ b/packages/grafana-ui/src/themes/light.ts
@@ -0,0 +1,70 @@
+import tinycolor from 'tinycolor2';
+import defaultTheme from './default';
+import { GrafanaTheme, GrafanaThemeType } from '../types/theme';
+
+const basicColors = {
+ black: '#000000',
+ white: '#ffffff',
+ dark1: '#13161d',
+ dark2: '#1e2028',
+ dark3: '#303133',
+ dark4: '#35373f',
+ dark5: '#41444b',
+ gray1: '#52545c',
+ gray2: '#767980',
+ gray3: '#acb6bf',
+ gray4: '#c7d0d9',
+ gray5: '#dde4ed',
+ gray6: '#e9edf2',
+ gray7: '#f7f8fa',
+ grayBlue: '#212327', // not used in light theme
+ blue: '#0083b3',
+ blueDark: '#005f81',
+ blueLight: '#00a8e6',
+ green: '#3aa655',
+ red: '#d44939',
+ yellow: '#ff851b',
+ pink: '#e671b8',
+ purple: '#9954bb',
+ variable: '#0083b3',
+ orange: '#ff7941',
+};
+
+const lightTheme: GrafanaTheme = {
+ ...defaultTheme,
+ type: GrafanaThemeType.Light,
+ name: 'Grafana Light',
+ colors: {
+ ...basicColors,
+ variable: basicColors.blue,
+ inputBlack: '#09090b',
+ queryRed: basicColors.red,
+ queryGreen: basicColors.green,
+ queryPurple: basicColors.purple,
+ queryKeyword: basicColors.blue,
+ queryOrange: basicColors.orange,
+ online: '#01a64f',
+ warn: '#f79520',
+ critical: '#ec2128',
+ bodyBg: basicColors.gray7,
+ pageBg: basicColors.gray7,
+ bodyColor: basicColors.gray1,
+ textColor: basicColors.gray1,
+ textColorStrong: basicColors.dark2,
+ textColorWeak: basicColors.gray2,
+ textColorEmphasis: basicColors.gray5,
+ textColorFaint: basicColors.dark4,
+ linkColor: basicColors.gray1,
+ linkColorDisabled: new tinycolor(basicColors.gray1).lighten(30).toString(),
+ linkColorHover: new tinycolor(basicColors.gray1).darken(20).toString(),
+ linkColorExternal: basicColors.blueLight,
+ headingColor: basicColors.gray1,
+ },
+ background: {
+ dropdown: basicColors.white,
+ scrollbar: basicColors.gray5,
+ scrollbar2: basicColors.gray5,
+ },
+};
+
+export default lightTheme;
diff --git a/packages/grafana-ui/src/themes/selectThemeVariant.test.ts b/packages/grafana-ui/src/themes/selectThemeVariant.test.ts
new file mode 100644
index 00000000000..66cb02a2372
--- /dev/null
+++ b/packages/grafana-ui/src/themes/selectThemeVariant.test.ts
@@ -0,0 +1,52 @@
+import { GrafanaThemeType } from '../types/theme';
+import { selectThemeVariant } from './selectThemeVariant';
+import { mockTheme } from './index';
+
+const lightThemeMock = {
+ color: {
+ red: '#ff0000',
+ green: '#00ff00',
+ },
+};
+
+const darkThemeMock = {
+ color: {
+ red: '#ff0000',
+ green: '#00ff00',
+ },
+};
+
+describe('Theme variable variant selector', () => {
+ // @ts-ignore
+ const restoreTheme = mockTheme(name => (name === GrafanaThemeType.Light ? lightThemeMock : darkThemeMock));
+
+ afterAll(() => {
+ restoreTheme();
+ });
+ it('return correct variable value for given theme', () => {
+ const theme = lightThemeMock;
+
+ const selectedValue = selectThemeVariant(
+ {
+ dark: theme.color.red,
+ light: theme.color.green,
+ },
+ GrafanaThemeType.Light
+ );
+
+ expect(selectedValue).toBe(lightThemeMock.color.green);
+ });
+
+ it('return dark theme variant if no theme given', () => {
+ const theme = lightThemeMock;
+
+ const selectedValue = selectThemeVariant(
+ {
+ dark: theme.color.red,
+ light: theme.color.green,
+ }
+ );
+
+ expect(selectedValue).toBe(lightThemeMock.color.red);
+ });
+});
diff --git a/packages/grafana-ui/src/themes/selectThemeVariant.ts b/packages/grafana-ui/src/themes/selectThemeVariant.ts
new file mode 100644
index 00000000000..e7e8e780222
--- /dev/null
+++ b/packages/grafana-ui/src/themes/selectThemeVariant.ts
@@ -0,0 +1,9 @@
+import { GrafanaThemeType } from '../types/theme';
+
+type VariantDescriptor = {
+ [key in GrafanaThemeType]: string | number;
+};
+
+export const selectThemeVariant = (variants: VariantDescriptor, currentTheme?: GrafanaThemeType) => {
+ return variants[currentTheme || GrafanaThemeType.Dark];
+};
diff --git a/packages/grafana-ui/src/types/index.ts b/packages/grafana-ui/src/types/index.ts
index e23b5e63af8..81bdf741f30 100644
--- a/packages/grafana-ui/src/types/index.ts
+++ b/packages/grafana-ui/src/types/index.ts
@@ -1,14 +1,7 @@
+
export * from './data';
export * from './time';
export * from './panel';
export * from './plugin';
export * from './datasource';
-
-export enum GrafanaTheme {
- Light = 'light',
- Dark = 'dark',
-}
-
-export interface Themeable {
- theme?: GrafanaTheme;
-}
+export * from './theme';
diff --git a/packages/grafana-ui/src/types/theme.ts b/packages/grafana-ui/src/types/theme.ts
new file mode 100644
index 00000000000..8a79658b423
--- /dev/null
+++ b/packages/grafana-ui/src/types/theme.ts
@@ -0,0 +1,129 @@
+export enum GrafanaThemeType {
+ Light = 'light',
+ Dark = 'dark',
+}
+
+export interface GrafanaTheme {
+ type: GrafanaThemeType;
+ name: string;
+ // TODO: not sure if should be a part of theme
+ brakpoints: {
+ xs: string;
+ s: string;
+ m: string;
+ l: string;
+ xl: string;
+ };
+ typography: {
+ fontFamily: {
+ sansSerif: string;
+ serif: string;
+ monospace: string;
+ };
+ size: {
+ base: string;
+ xs: string;
+ s: string;
+ m: string;
+ l: string;
+ };
+ weight: {
+ light: number;
+ normal: number;
+ semibold: number;
+ };
+ lineHeight: {
+ xs: number; //1
+ s: number; //1.1
+ m: number; // 4/3
+ l: number; // 1.5
+ };
+ // TODO: Refactor to use size instead of custom defs
+ heading: {
+ h1: string;
+ h2: string;
+ h3: string;
+ h4: string;
+ h5: string;
+ h6: string;
+ };
+ };
+ spacing: {
+ xs: string;
+ s: string;
+ m: string;
+ l: string;
+ gutter: string;
+ };
+ border: {
+ radius: {
+ xs: string;
+ s: string;
+ m: string;
+ };
+ };
+ background: {
+ dropdown: string;
+ scrollbar: string;
+ scrollbar2: string;
+ };
+ colors: {
+ black: string;
+ white: string;
+ dark1: string;
+ dark2: string;
+ dark3: string;
+ dark4: string;
+ dark5: string;
+ gray1: string;
+ gray2: string;
+ gray3: string;
+ gray4: string;
+ gray5: string;
+ gray6: string;
+ gray7: string;
+ grayBlue: string;
+ inputBlack: string;
+
+ // Accent colors
+ blue: string;
+ blueLight: string;
+ blueDark: string;
+ green: string;
+ red: string;
+ yellow: string;
+ pink: string;
+ purple: string;
+ variable: string;
+ orange: string;
+ queryRed: string;
+ queryGreen: string;
+ queryPurple: string;
+ queryKeyword: string;
+ queryOrange: string;
+
+ // Status colors
+ online: string;
+ warn: string;
+ critical: string;
+
+ // TODO: move to background section
+ bodyBg: string;
+ pageBg: string;
+ bodyColor: string;
+ textColor: string;
+ textColorStrong: string;
+ textColorWeak: string;
+ textColorFaint: string;
+ textColorEmphasis: string;
+ linkColor: string;
+ linkColorDisabled: string;
+ linkColorHover: string;
+ linkColorExternal: string;
+ headingColor: string;
+ };
+}
+
+export interface Themeable {
+ theme: GrafanaTheme;
+}
diff --git a/packages/grafana-ui/src/utils/namedColorsPalette.test.ts b/packages/grafana-ui/src/utils/namedColorsPalette.test.ts
index c6a1aaf0dd0..aa57b46636c 100644
--- a/packages/grafana-ui/src/utils/namedColorsPalette.test.ts
+++ b/packages/grafana-ui/src/utils/namedColorsPalette.test.ts
@@ -5,20 +5,20 @@ import {
getColorFromHexRgbOrName,
getColorDefinitionByName,
} from './namedColorsPalette';
-import { GrafanaTheme } from '../types/index';
+import { GrafanaThemeType } from '../types/index';
describe('colors', () => {
const SemiDarkBlue = getColorDefinitionByName('semi-dark-blue');
describe('getColorDefinition', () => {
it('returns undefined for unknown hex', () => {
- expect(getColorDefinition('#ff0000', GrafanaTheme.Light)).toBeUndefined();
- expect(getColorDefinition('#ff0000', GrafanaTheme.Dark)).toBeUndefined();
+ expect(getColorDefinition('#ff0000', GrafanaThemeType.Light)).toBeUndefined();
+ expect(getColorDefinition('#ff0000', GrafanaThemeType.Dark)).toBeUndefined();
});
it('returns definition for known hex', () => {
- expect(getColorDefinition(SemiDarkBlue.variants.light, GrafanaTheme.Light)).toEqual(SemiDarkBlue);
- expect(getColorDefinition(SemiDarkBlue.variants.dark, GrafanaTheme.Dark)).toEqual(SemiDarkBlue);
+ expect(getColorDefinition(SemiDarkBlue.variants.light, GrafanaThemeType.Light)).toEqual(SemiDarkBlue);
+ expect(getColorDefinition(SemiDarkBlue.variants.dark, GrafanaThemeType.Dark)).toEqual(SemiDarkBlue);
});
});
@@ -28,8 +28,8 @@ describe('colors', () => {
});
it('returns name for known hex', () => {
- expect(getColorName(SemiDarkBlue.variants.light, GrafanaTheme.Light)).toEqual(SemiDarkBlue.name);
- expect(getColorName(SemiDarkBlue.variants.dark, GrafanaTheme.Dark)).toEqual(SemiDarkBlue.name);
+ expect(getColorName(SemiDarkBlue.variants.light, GrafanaThemeType.Light)).toEqual(SemiDarkBlue.name);
+ expect(getColorName(SemiDarkBlue.variants.dark, GrafanaThemeType.Dark)).toEqual(SemiDarkBlue.name);
});
});
@@ -53,12 +53,14 @@ describe('colors', () => {
});
it("returns correct variant's hex for known color if theme specified", () => {
- expect(getColorFromHexRgbOrName(SemiDarkBlue.name, GrafanaTheme.Light)).toBe(SemiDarkBlue.variants.light);
+ expect(getColorFromHexRgbOrName(SemiDarkBlue.name, GrafanaThemeType.Light)).toBe(SemiDarkBlue.variants.light);
});
it('returns color if specified as hex or rgb/a', () => {
expect(getColorFromHexRgbOrName('ff0000')).toBe('ff0000');
expect(getColorFromHexRgbOrName('#ff0000')).toBe('#ff0000');
+ expect(getColorFromHexRgbOrName('#FF0000')).toBe('#FF0000');
+ expect(getColorFromHexRgbOrName('#CCC')).toBe('#CCC');
expect(getColorFromHexRgbOrName('rgb(0,0,0)')).toBe('rgb(0,0,0)');
expect(getColorFromHexRgbOrName('rgba(0,0,0,1)')).toBe('rgba(0,0,0,1)');
});
diff --git a/packages/grafana-ui/src/utils/namedColorsPalette.ts b/packages/grafana-ui/src/utils/namedColorsPalette.ts
index 5312b27ad26..ee5741e794e 100644
--- a/packages/grafana-ui/src/utils/namedColorsPalette.ts
+++ b/packages/grafana-ui/src/utils/namedColorsPalette.ts
@@ -1,5 +1,5 @@
import { flatten } from 'lodash';
-import { GrafanaTheme } from '../types';
+import { GrafanaThemeType } from '../types';
type Hue = 'green' | 'yellow' | 'red' | 'blue' | 'orange' | 'purple';
@@ -68,16 +68,16 @@ export const getColorDefinitionByName = (name: Color): ColorDefinition => {
return flatten(Array.from(getNamedColorPalette().values())).filter(definition => definition.name === name)[0];
};
-export const getColorDefinition = (hex: string, theme: GrafanaTheme): ColorDefinition | undefined => {
+export const getColorDefinition = (hex: string, theme: GrafanaThemeType): ColorDefinition | undefined => {
return flatten(Array.from(getNamedColorPalette().values())).filter(definition => definition.variants[theme] === hex)[0];
};
const isHex = (color: string) => {
- const hexRegex = /^((0x){0,1}|#{0,1})([0-9A-F]{8}|[0-9A-F]{6})$/gi;
+ const hexRegex = /^((0x){0,1}|#{0,1})([0-9A-F]{8}|[0-9A-F]{6}|[0-9A-F]{3})$/gi;
return hexRegex.test(color);
};
-export const getColorName = (color?: string, theme?: GrafanaTheme): Color | undefined => {
+export const getColorName = (color?: string, theme?: GrafanaThemeType): Color | undefined => {
if (!color) {
return undefined;
}
@@ -86,7 +86,7 @@ export const getColorName = (color?: string, theme?: GrafanaTheme): Color | unde
return undefined;
}
if (isHex(color)) {
- const definition = getColorDefinition(color, theme || GrafanaTheme.Dark);
+ const definition = getColorDefinition(color, theme || GrafanaThemeType.Dark);
return definition ? definition.name : undefined;
}
@@ -98,7 +98,7 @@ export const getColorByName = (colorName: string) => {
return definition.length > 0 ? definition[0] : undefined;
};
-export const getColorFromHexRgbOrName = (color: string, theme?: GrafanaTheme): string => {
+export const getColorFromHexRgbOrName = (color: string, theme?: GrafanaThemeType): string => {
if (color.indexOf('rgb') > -1 || isHex(color)) {
return color;
}
@@ -112,14 +112,14 @@ export const getColorFromHexRgbOrName = (color: string, theme?: GrafanaTheme): s
return theme ? colorDefinition.variants[theme] : colorDefinition.variants.dark;
};
-export const getColorForTheme = (color: ColorDefinition, theme?: GrafanaTheme) => {
+export const getColorForTheme = (color: ColorDefinition, theme?: GrafanaThemeType) => {
return theme ? color.variants[theme] : color.variants.dark;
};
const buildNamedColorsPalette = () => {
const palette = new Map();
- const BasicGreen = buildColorDefinition('green', 'green', ['#56A64B', '#73BF69'], true);
+ const BasicGreen = buildColorDefinition('green', 'green', ['#56A64B', '#73BF69'], true);
const DarkGreen = buildColorDefinition('green', 'dark-green', ['#19730E', '#37872D']);
const SemiDarkGreen = buildColorDefinition('green', 'semi-dark-green', ['#37872D', '#56A64B']);
const LightGreen = buildColorDefinition('green', 'light-green', ['#73BF69', '#96D98D']);
diff --git a/packages/grafana-ui/src/utils/storybook/themeKnob.ts b/packages/grafana-ui/src/utils/storybook/themeKnob.ts
deleted file mode 100644
index a3733462bea..00000000000
--- a/packages/grafana-ui/src/utils/storybook/themeKnob.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import { select } from '@storybook/addon-knobs';
-import { GrafanaTheme } from '../../types';
-
-export const getThemeKnob = (defaultTheme: GrafanaTheme = GrafanaTheme.Dark) => {
- return select(
- 'Theme',
- {
- Default: defaultTheme,
- Light: GrafanaTheme.Light,
- Dark: GrafanaTheme.Dark,
- },
- defaultTheme
- );
-};
diff --git a/packages/grafana-ui/src/utils/storybook/withTheme.tsx b/packages/grafana-ui/src/utils/storybook/withTheme.tsx
new file mode 100644
index 00000000000..5417af1de05
--- /dev/null
+++ b/packages/grafana-ui/src/utils/storybook/withTheme.tsx
@@ -0,0 +1,41 @@
+import React from 'react';
+import { RenderFunction } from '@storybook/react';
+import { ThemeContext } from '../../themes/ThemeContext';
+import { select } from '@storybook/addon-knobs';
+import { getTheme } from '../../themes';
+import { GrafanaThemeType } from '../../types';
+
+const ThemableStory: React.FunctionComponent<{}> = ({ children }) => {
+ const themeKnob = select(
+ 'Theme',
+ {
+ Light: GrafanaThemeType.Light,
+ Dark: GrafanaThemeType.Dark,
+ },
+ GrafanaThemeType.Dark
+ );
+
+ return (
+
+ {children}
+
+
+ );
+};
+
+// Temporary solution. When we update to Storybook V5 we will be able to pass data from decorator to story
+// https://github.com/storybooks/storybook/issues/340#issuecomment-456013702
+export const renderComponentWithTheme = (component: React.ComponentType, props: any) => {
+ return (
+
+ {theme => {
+ return React.createElement(component, {
+ ...props,
+ theme,
+ });
+ }}
+
+ );
+};
+
+export const withTheme = (story: RenderFunction) => {story()} ;
diff --git a/pkg/api/annotations.go b/pkg/api/annotations.go
index 242b5531f51..de9d2517caa 100644
--- a/pkg/api/annotations.go
+++ b/pkg/api/annotations.go
@@ -210,6 +210,65 @@ func UpdateAnnotation(c *m.ReqContext, cmd dtos.UpdateAnnotationsCmd) Response {
return Success("Annotation updated")
}
+func PatchAnnotation(c *m.ReqContext, cmd dtos.PatchAnnotationsCmd) Response {
+ annotationID := c.ParamsInt64(":annotationId")
+
+ repo := annotations.GetRepository()
+
+ if resp := canSave(c, repo, annotationID); resp != nil {
+ return resp
+ }
+
+ items, err := repo.Find(&annotations.ItemQuery{AnnotationId: annotationID, OrgId: c.OrgId})
+
+ if err != nil || len(items) == 0 {
+ return Error(404, "Could not find annotation to update", err)
+ }
+
+ existing := annotations.Item{
+ OrgId: c.OrgId,
+ UserId: c.UserId,
+ Id: annotationID,
+ Epoch: items[0].Time,
+ Text: items[0].Text,
+ Tags: items[0].Tags,
+ RegionId: items[0].RegionId,
+ }
+
+ if cmd.Tags != nil {
+ existing.Tags = cmd.Tags
+ }
+
+ if cmd.Text != "" && cmd.Text != existing.Text {
+ existing.Text = cmd.Text
+ }
+
+ if cmd.Time > 0 && cmd.Time != existing.Epoch {
+ existing.Epoch = cmd.Time
+ }
+
+ if err := repo.Update(&existing); err != nil {
+ return Error(500, "Failed to update annotation", err)
+ }
+
+ // Update region end time if provided
+ if existing.RegionId != 0 && cmd.TimeEnd > 0 {
+ itemRight := existing
+ itemRight.RegionId = existing.Id
+ itemRight.Epoch = cmd.TimeEnd
+
+ // We don't know id of region right event, so set it to 0 and find then using query like
+ // ... WHERE region_id = AND id != ...
+ itemRight.Id = 0
+
+ if err := repo.Update(&itemRight); err != nil {
+ return Error(500, "Failed to update annotation for region end time", err)
+ }
+ }
+
+ return Success("Annotation patched")
+}
+
func DeleteAnnotations(c *m.ReqContext, cmd dtos.DeleteAnnotationsCmd) Response {
repo := annotations.GetRepository()
diff --git a/pkg/api/annotations_test.go b/pkg/api/annotations_test.go
index 08f3018c694..ebdd867a031 100644
--- a/pkg/api/annotations_test.go
+++ b/pkg/api/annotations_test.go
@@ -27,6 +27,12 @@ func TestAnnotationsApiEndpoint(t *testing.T) {
IsRegion: false,
}
+ patchCmd := dtos.PatchAnnotationsCmd{
+ Time: 1000,
+ Text: "annotation text",
+ Tags: []string{"tag1", "tag2"},
+ }
+
Convey("When user is an Org Viewer", func() {
role := m.ROLE_VIEWER
Convey("Should not be allowed to save an annotation", func() {
@@ -40,6 +46,11 @@ func TestAnnotationsApiEndpoint(t *testing.T) {
So(sc.resp.Code, ShouldEqual, 403)
})
+ patchAnnotationScenario("When calling PATCH on", "/api/annotations/1", "/api/annotations/:annotationId", role, patchCmd, func(sc *scenarioContext) {
+ sc.fakeReqWithParams("PATCH", sc.url, map[string]string{}).exec()
+ So(sc.resp.Code, ShouldEqual, 403)
+ })
+
loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/annotations/1", "/api/annotations/:annotationId", role, func(sc *scenarioContext) {
sc.handlerFunc = DeleteAnnotationByID
sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec()
@@ -67,6 +78,11 @@ func TestAnnotationsApiEndpoint(t *testing.T) {
So(sc.resp.Code, ShouldEqual, 200)
})
+ patchAnnotationScenario("When calling PATCH on", "/api/annotations/1", "/api/annotations/:annotationId", role, patchCmd, func(sc *scenarioContext) {
+ sc.fakeReqWithParams("PATCH", sc.url, map[string]string{}).exec()
+ So(sc.resp.Code, ShouldEqual, 200)
+ })
+
loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/annotations/1", "/api/annotations/:annotationId", role, func(sc *scenarioContext) {
sc.handlerFunc = DeleteAnnotationByID
sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec()
@@ -100,6 +116,13 @@ func TestAnnotationsApiEndpoint(t *testing.T) {
Id: 1,
}
+ patchCmd := dtos.PatchAnnotationsCmd{
+ Time: 8000,
+ Text: "annotation text 50",
+ Tags: []string{"foo", "bar"},
+ Id: 1,
+ }
+
deleteCmd := dtos.DeleteAnnotationsCmd{
DashboardId: 1,
PanelId: 1,
@@ -136,6 +159,11 @@ func TestAnnotationsApiEndpoint(t *testing.T) {
So(sc.resp.Code, ShouldEqual, 403)
})
+ patchAnnotationScenario("When calling PATCH on", "/api/annotations/1", "/api/annotations/:annotationId", role, patchCmd, func(sc *scenarioContext) {
+ sc.fakeReqWithParams("PATCH", sc.url, map[string]string{}).exec()
+ So(sc.resp.Code, ShouldEqual, 403)
+ })
+
loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/annotations/1", "/api/annotations/:annotationId", role, func(sc *scenarioContext) {
sc.handlerFunc = DeleteAnnotationByID
sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec()
@@ -163,6 +191,11 @@ func TestAnnotationsApiEndpoint(t *testing.T) {
So(sc.resp.Code, ShouldEqual, 200)
})
+ patchAnnotationScenario("When calling PATCH on", "/api/annotations/1", "/api/annotations/:annotationId", role, patchCmd, func(sc *scenarioContext) {
+ sc.fakeReqWithParams("PATCH", sc.url, map[string]string{}).exec()
+ So(sc.resp.Code, ShouldEqual, 200)
+ })
+
loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/annotations/1", "/api/annotations/:annotationId", role, func(sc *scenarioContext) {
sc.handlerFunc = DeleteAnnotationByID
sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec()
@@ -189,6 +222,12 @@ func TestAnnotationsApiEndpoint(t *testing.T) {
sc.fakeReqWithParams("PUT", sc.url, map[string]string{}).exec()
So(sc.resp.Code, ShouldEqual, 200)
})
+
+ patchAnnotationScenario("When calling PATCH on", "/api/annotations/1", "/api/annotations/:annotationId", role, patchCmd, func(sc *scenarioContext) {
+ sc.fakeReqWithParams("PATCH", sc.url, map[string]string{}).exec()
+ So(sc.resp.Code, ShouldEqual, 200)
+ })
+
deleteAnnotationsScenario("When calling POST on", "/api/annotations/mass-delete", "/api/annotations/mass-delete", role, deleteCmd, func(sc *scenarioContext) {
sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
So(sc.resp.Code, ShouldEqual, 200)
@@ -264,6 +303,29 @@ func putAnnotationScenario(desc string, url string, routePattern string, role m.
})
}
+func patchAnnotationScenario(desc string, url string, routePattern string, role m.RoleType, cmd dtos.PatchAnnotationsCmd, fn scenarioFunc) {
+ Convey(desc+" "+url, func() {
+ defer bus.ClearBusHandlers()
+
+ sc := setupScenarioContext(url)
+ sc.defaultHandler = Wrap(func(c *m.ReqContext) Response {
+ sc.context = c
+ sc.context.UserId = TestUserID
+ sc.context.OrgId = TestOrgID
+ sc.context.OrgRole = role
+
+ return PatchAnnotation(c, cmd)
+ })
+
+ fakeAnnoRepo = &fakeAnnotationsRepo{}
+ annotations.SetRepository(fakeAnnoRepo)
+
+ sc.m.Patch(routePattern, sc.defaultHandler)
+
+ fn(sc)
+ })
+}
+
func deleteAnnotationsScenario(desc string, url string, routePattern string, role m.RoleType, cmd dtos.DeleteAnnotationsCmd, fn scenarioFunc) {
Convey(desc+" "+url, func() {
defer bus.ClearBusHandlers()
diff --git a/pkg/api/api.go b/pkg/api/api.go
index 980706d8355..0685ef3814d 100644
--- a/pkg/api/api.go
+++ b/pkg/api/api.go
@@ -354,6 +354,7 @@ func (hs *HTTPServer) registerRoutes() {
annotationsRoute.Post("/", bind(dtos.PostAnnotationsCmd{}), Wrap(PostAnnotation))
annotationsRoute.Delete("/:annotationId", Wrap(DeleteAnnotationByID))
annotationsRoute.Put("/:annotationId", bind(dtos.UpdateAnnotationsCmd{}), Wrap(UpdateAnnotation))
+ annotationsRoute.Patch("/:annotationId", bind(dtos.PatchAnnotationsCmd{}), Wrap(PatchAnnotation))
annotationsRoute.Delete("/region/:regionId", Wrap(DeleteAnnotationRegion))
annotationsRoute.Post("/graphite", reqEditorRole, bind(dtos.PostGraphiteAnnotationsCmd{}), Wrap(PostGraphiteAnnotation))
})
diff --git a/pkg/api/common_test.go b/pkg/api/common_test.go
index fe02c94e277..3f3a50aae69 100644
--- a/pkg/api/common_test.go
+++ b/pkg/api/common_test.go
@@ -94,14 +94,13 @@ func (sc *scenarioContext) fakeReqWithParams(method, url string, queryParams map
}
type scenarioContext struct {
- m *macaron.Macaron
- context *m.ReqContext
- resp *httptest.ResponseRecorder
- handlerFunc handlerFunc
- defaultHandler macaron.Handler
- req *http.Request
- url string
- userAuthTokenService *fakeUserAuthTokenService
+ m *macaron.Macaron
+ context *m.ReqContext
+ resp *httptest.ResponseRecorder
+ handlerFunc handlerFunc
+ defaultHandler macaron.Handler
+ req *http.Request
+ url string
}
func (sc *scenarioContext) exec() {
@@ -123,30 +122,7 @@ func setupScenarioContext(url string) *scenarioContext {
Delims: macaron.Delims{Left: "[[", Right: "]]"},
}))
- sc.userAuthTokenService = newFakeUserAuthTokenService()
- sc.m.Use(middleware.GetContextHandler(sc.userAuthTokenService))
+ sc.m.Use(middleware.GetContextHandler(nil))
return sc
}
-
-type fakeUserAuthTokenService struct {
- initContextWithTokenProvider func(ctx *m.ReqContext, orgID int64) bool
-}
-
-func newFakeUserAuthTokenService() *fakeUserAuthTokenService {
- return &fakeUserAuthTokenService{
- initContextWithTokenProvider: func(ctx *m.ReqContext, orgID int64) bool {
- return false
- },
- }
-}
-
-func (s *fakeUserAuthTokenService) InitContextWithToken(ctx *m.ReqContext, orgID int64) bool {
- return s.initContextWithTokenProvider(ctx, orgID)
-}
-
-func (s *fakeUserAuthTokenService) UserAuthenticatedHook(user *m.User, c *m.ReqContext) error {
- return nil
-}
-
-func (s *fakeUserAuthTokenService) SignOutUser(c *m.ReqContext) error { return nil }
diff --git a/pkg/api/dtos/annotations.go b/pkg/api/dtos/annotations.go
index c917b0d9feb..bdee8599fea 100644
--- a/pkg/api/dtos/annotations.go
+++ b/pkg/api/dtos/annotations.go
@@ -22,6 +22,14 @@ type UpdateAnnotationsCmd struct {
TimeEnd int64 `json:"timeEnd"`
}
+type PatchAnnotationsCmd struct {
+ Id int64 `json:"id"`
+ Time int64 `json:"time"`
+ Text string `json:"text"`
+ Tags []string `json:"tags"`
+ TimeEnd int64 `json:"timeEnd"`
+}
+
type DeleteAnnotationsCmd struct {
AlertId int64 `json:"alertId"`
DashboardId int64 `json:"dashboardId"`
diff --git a/pkg/api/dtos/playlist.go b/pkg/api/dtos/playlist.go
index 317ff83339a..7f43bb4df8a 100644
--- a/pkg/api/dtos/playlist.go
+++ b/pkg/api/dtos/playlist.go
@@ -5,6 +5,7 @@ type PlaylistDashboard struct {
Slug string `json:"slug"`
Title string `json:"title"`
Uri string `json:"uri"`
+ Url string `json:"url"`
Order int `json:"order"`
}
diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go
index 7b7c1478a4c..cadf6896bf4 100644
--- a/pkg/api/http_server.go
+++ b/pkg/api/http_server.go
@@ -21,7 +21,6 @@ import (
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/registry"
- "github.com/grafana/grafana/pkg/services/auth"
"github.com/grafana/grafana/pkg/services/cache"
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/hooks"
@@ -48,14 +47,14 @@ type HTTPServer struct {
streamManager *live.StreamManager
httpSrv *http.Server
- RouteRegister routing.RouteRegister `inject:""`
- Bus bus.Bus `inject:""`
- RenderService rendering.Service `inject:""`
- Cfg *setting.Cfg `inject:""`
- HooksService *hooks.HooksService `inject:""`
- CacheService *cache.CacheService `inject:""`
- DatasourceCache datasources.CacheService `inject:""`
- AuthTokenService auth.UserAuthTokenService `inject:""`
+ RouteRegister routing.RouteRegister `inject:""`
+ Bus bus.Bus `inject:""`
+ RenderService rendering.Service `inject:""`
+ Cfg *setting.Cfg `inject:""`
+ HooksService *hooks.HooksService `inject:""`
+ CacheService *cache.CacheService `inject:""`
+ DatasourceCache datasources.CacheService `inject:""`
+ AuthTokenService models.UserTokenService `inject:""`
}
func (hs *HTTPServer) Init() error {
diff --git a/pkg/api/login.go b/pkg/api/login.go
index 49da147724e..106a48dd6a8 100644
--- a/pkg/api/login.go
+++ b/pkg/api/login.go
@@ -10,6 +10,7 @@ import (
"github.com/grafana/grafana/pkg/log"
"github.com/grafana/grafana/pkg/login"
"github.com/grafana/grafana/pkg/metrics"
+ "github.com/grafana/grafana/pkg/middleware"
m "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util"
@@ -126,17 +127,23 @@ func (hs *HTTPServer) LoginPost(c *m.ReqContext, cmd dtos.LoginCommand) Response
func (hs *HTTPServer) loginUserWithUser(user *m.User, c *m.ReqContext) {
if user == nil {
- hs.log.Error("User login with nil user")
+ hs.log.Error("user login with nil user")
}
- err := hs.AuthTokenService.UserAuthenticatedHook(user, c)
+ userToken, err := hs.AuthTokenService.CreateToken(user.Id, c.RemoteAddr(), c.Req.UserAgent())
if err != nil {
- hs.log.Error("User auth hook failed", "error", err)
+ hs.log.Error("failed to create auth token", "error", err)
}
+
+ middleware.WriteSessionCookie(c, userToken.UnhashedToken, hs.Cfg.LoginMaxLifetimeDays)
}
func (hs *HTTPServer) Logout(c *m.ReqContext) {
- hs.AuthTokenService.SignOutUser(c)
+ if err := hs.AuthTokenService.RevokeToken(c.UserToken); err != nil && err != m.ErrUserTokenNotFound {
+ hs.log.Error("failed to revoke auth token", "error", err)
+ }
+
+ middleware.WriteSessionCookie(c, "", -1)
if setting.SignoutRedirectUrl != "" {
c.Redirect(setting.SignoutRedirectUrl)
@@ -176,7 +183,8 @@ func (hs *HTTPServer) trySetEncryptedCookie(ctx *m.ReqContext, cookieName string
Value: hex.EncodeToString(encryptedError),
HttpOnly: true,
Path: setting.AppSubUrl + "/",
- Secure: hs.Cfg.SecurityHTTPSCookies,
+ Secure: hs.Cfg.CookieSecure,
+ SameSite: hs.Cfg.CookieSameSite,
})
return nil
diff --git a/pkg/api/login_oauth.go b/pkg/api/login_oauth.go
index 4160d48733e..87a8ecc876f 100644
--- a/pkg/api/login_oauth.go
+++ b/pkg/api/login_oauth.go
@@ -214,7 +214,8 @@ func (hs *HTTPServer) writeCookie(w http.ResponseWriter, name string, value stri
Value: value,
HttpOnly: true,
Path: setting.AppSubUrl + "/",
- Secure: hs.Cfg.SecurityHTTPSCookies,
+ Secure: hs.Cfg.CookieSecure,
+ SameSite: hs.Cfg.CookieSameSite,
})
}
diff --git a/pkg/api/playlist_play.go b/pkg/api/playlist_play.go
index e82c7b438b4..5ca136c32c4 100644
--- a/pkg/api/playlist_play.go
+++ b/pkg/api/playlist_play.go
@@ -26,6 +26,7 @@ func populateDashboardsByID(dashboardByIDs []int64, dashboardIDOrder map[int64]i
Slug: item.Slug,
Title: item.Title,
Uri: "db/" + item.Slug,
+ Url: m.GetDashboardUrl(item.Uid, item.Slug),
Order: dashboardIDOrder[item.Id],
})
}
diff --git a/pkg/cmd/grafana-server/server.go b/pkg/cmd/grafana-server/server.go
index 4781361b9b9..f663e6be895 100644
--- a/pkg/cmd/grafana-server/server.go
+++ b/pkg/cmd/grafana-server/server.go
@@ -32,6 +32,7 @@ import (
_ "github.com/grafana/grafana/pkg/metrics"
_ "github.com/grafana/grafana/pkg/plugins"
_ "github.com/grafana/grafana/pkg/services/alerting"
+ _ "github.com/grafana/grafana/pkg/services/auth"
_ "github.com/grafana/grafana/pkg/services/cleanup"
_ "github.com/grafana/grafana/pkg/services/notifications"
_ "github.com/grafana/grafana/pkg/services/provisioning"
diff --git a/pkg/login/ldap.go b/pkg/login/ldap.go
index 402160ef5e3..c15cb865bd3 100644
--- a/pkg/login/ldap.go
+++ b/pkg/login/ldap.go
@@ -273,23 +273,35 @@ func (a *ldapAuther) initialBind(username, userPassword string) error {
return nil
}
+func appendIfNotEmpty(slice []string, values ...string) []string {
+ for _, v := range values {
+ if v != "" {
+ slice = append(slice, v)
+ }
+ }
+ return slice
+}
+
func (a *ldapAuther) searchForUser(username string) (*LdapUserInfo, error) {
var searchResult *ldap.SearchResult
var err error
for _, searchBase := range a.server.SearchBaseDNs {
+ attributes := make([]string, 0)
+ inputs := a.server.Attr
+ attributes = appendIfNotEmpty(attributes,
+ inputs.Username,
+ inputs.Surname,
+ inputs.Email,
+ inputs.Name,
+ inputs.MemberOf)
+
searchReq := ldap.SearchRequest{
BaseDN: searchBase,
Scope: ldap.ScopeWholeSubtree,
DerefAliases: ldap.NeverDerefAliases,
- Attributes: []string{
- a.server.Attr.Username,
- a.server.Attr.Surname,
- a.server.Attr.Email,
- a.server.Attr.Name,
- a.server.Attr.MemberOf,
- },
- Filter: strings.Replace(a.server.SearchFilter, "%s", ldap.EscapeFilter(username), -1),
+ Attributes: attributes,
+ Filter: strings.Replace(a.server.SearchFilter, "%s", ldap.EscapeFilter(username), -1),
}
a.log.Debug("Ldap Search For User Request", "info", spew.Sdump(searchReq))
diff --git a/pkg/login/ldap_test.go b/pkg/login/ldap_test.go
index f1cf44dc554..c02fa02e030 100644
--- a/pkg/login/ldap_test.go
+++ b/pkg/login/ldap_test.go
@@ -6,6 +6,7 @@ import (
"testing"
"github.com/grafana/grafana/pkg/bus"
+ "github.com/grafana/grafana/pkg/log"
m "github.com/grafana/grafana/pkg/models"
. "github.com/smartystreets/goconvey/convey"
"gopkg.in/ldap.v3"
@@ -322,11 +323,51 @@ func TestLdapAuther(t *testing.T) {
So(sc.addOrgUserCmd.Role, ShouldEqual, "Admin")
})
})
+
+ Convey("When searching for a user and not all five attributes are mapped", t, func() {
+ mockLdapConnection := &mockLdapConn{}
+ entry := ldap.Entry{
+ DN: "dn", Attributes: []*ldap.EntryAttribute{
+ {Name: "username", Values: []string{"roelgerrits"}},
+ {Name: "surname", Values: []string{"Gerrits"}},
+ {Name: "email", Values: []string{"roel@test.com"}},
+ {Name: "name", Values: []string{"Roel"}},
+ {Name: "memberof", Values: []string{"admins"}},
+ }}
+ result := ldap.SearchResult{Entries: []*ldap.Entry{&entry}}
+ mockLdapConnection.setSearchResult(&result)
+
+ // Set up attribute map without surname and email
+ ldapAuther := &ldapAuther{
+ server: &LdapServerConf{
+ Attr: LdapAttributeMap{
+ Username: "username",
+ Name: "name",
+ MemberOf: "memberof",
+ },
+ SearchBaseDNs: []string{"BaseDNHere"},
+ },
+ conn: mockLdapConnection,
+ log: log.New("test-logger"),
+ }
+
+ searchResult, err := ldapAuther.searchForUser("roelgerrits")
+
+ So(err, ShouldBeNil)
+ So(searchResult, ShouldNotBeNil)
+
+ // User should be searched in ldap
+ So(mockLdapConnection.searchCalled, ShouldBeTrue)
+
+ // No empty attributes should be added to the search request
+ So(len(mockLdapConnection.searchAttributes), ShouldEqual, 3)
+ })
}
type mockLdapConn struct {
- result *ldap.SearchResult
- searchCalled bool
+ result *ldap.SearchResult
+ searchCalled bool
+ searchAttributes []string
}
func (c *mockLdapConn) Bind(username, password string) error {
@@ -339,8 +380,9 @@ func (c *mockLdapConn) setSearchResult(result *ldap.SearchResult) {
c.result = result
}
-func (c *mockLdapConn) Search(*ldap.SearchRequest) (*ldap.SearchResult, error) {
+func (c *mockLdapConn) Search(sr *ldap.SearchRequest) (*ldap.SearchResult, error) {
c.searchCalled = true
+ c.searchAttributes = sr.Attributes
return c.result, nil
}
diff --git a/pkg/middleware/middleware.go b/pkg/middleware/middleware.go
index 3722ac3058f..fa335eb10d9 100644
--- a/pkg/middleware/middleware.go
+++ b/pkg/middleware/middleware.go
@@ -1,13 +1,15 @@
package middleware
import (
+ "net/http"
+ "net/url"
"strconv"
+ "time"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/apikeygen"
"github.com/grafana/grafana/pkg/log"
m "github.com/grafana/grafana/pkg/models"
- "github.com/grafana/grafana/pkg/services/auth"
"github.com/grafana/grafana/pkg/services/session"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util"
@@ -21,7 +23,7 @@ var (
ReqOrgAdmin = RoleAuth(m.ROLE_ADMIN)
)
-func GetContextHandler(ats auth.UserAuthTokenService) macaron.Handler {
+func GetContextHandler(ats m.UserTokenService) macaron.Handler {
return func(c *macaron.Context) {
ctx := &m.ReqContext{
Context: c,
@@ -49,7 +51,7 @@ func GetContextHandler(ats auth.UserAuthTokenService) macaron.Handler {
case initContextWithApiKey(ctx):
case initContextWithBasicAuth(ctx, orgId):
case initContextWithAuthProxy(ctx, orgId):
- case ats.InitContextWithToken(ctx, orgId):
+ case initContextWithToken(ats, ctx, orgId):
case initContextWithAnonymousUser(ctx):
}
@@ -166,6 +168,69 @@ func initContextWithBasicAuth(ctx *m.ReqContext, orgId int64) bool {
return true
}
+func initContextWithToken(authTokenService m.UserTokenService, ctx *m.ReqContext, orgID int64) bool {
+ rawToken := ctx.GetCookie(setting.LoginCookieName)
+ if rawToken == "" {
+ return false
+ }
+
+ token, err := authTokenService.LookupToken(rawToken)
+ if err != nil {
+ ctx.Logger.Error("failed to look up user based on cookie", "error", err)
+ WriteSessionCookie(ctx, "", -1)
+ return false
+ }
+
+ query := m.GetSignedInUserQuery{UserId: token.UserId, OrgId: orgID}
+ if err := bus.Dispatch(&query); err != nil {
+ ctx.Logger.Error("failed to get user with id", "userId", token.UserId, "error", err)
+ return false
+ }
+
+ ctx.SignedInUser = query.Result
+ ctx.IsSignedIn = true
+ ctx.UserToken = token
+
+ rotated, err := authTokenService.TryRotateToken(token, ctx.RemoteAddr(), ctx.Req.UserAgent())
+ if err != nil {
+ ctx.Logger.Error("failed to rotate token", "error", err)
+ return true
+ }
+
+ if rotated {
+ WriteSessionCookie(ctx, token.UnhashedToken, setting.LoginMaxLifetimeDays)
+ }
+
+ return true
+}
+
+func WriteSessionCookie(ctx *m.ReqContext, value string, maxLifetimeDays int) {
+ if setting.Env == setting.DEV {
+ ctx.Logger.Info("new token", "unhashed token", value)
+ }
+
+ var maxAge int
+ if maxLifetimeDays <= 0 {
+ maxAge = -1
+ } else {
+ maxAgeHours := (time.Duration(setting.LoginMaxLifetimeDays) * 24 * time.Hour) + time.Hour
+ maxAge = int(maxAgeHours.Seconds())
+ }
+
+ ctx.Resp.Header().Del("Set-Cookie")
+ cookie := http.Cookie{
+ Name: setting.LoginCookieName,
+ Value: url.QueryEscape(value),
+ HttpOnly: true,
+ Path: setting.AppSubUrl + "/",
+ Secure: setting.CookieSecure,
+ MaxAge: maxAge,
+ SameSite: setting.CookieSameSite,
+ }
+
+ http.SetCookie(ctx.Resp, &cookie)
+}
+
func AddDefaultResponseHeaders() macaron.Handler {
return func(ctx *m.ReqContext) {
if ctx.IsApiRequest() && ctx.Req.Method == "GET" {
diff --git a/pkg/middleware/middleware_test.go b/pkg/middleware/middleware_test.go
index 4679c449853..8545c3856c9 100644
--- a/pkg/middleware/middleware_test.go
+++ b/pkg/middleware/middleware_test.go
@@ -6,6 +6,7 @@ import (
"net/http/httptest"
"path/filepath"
"testing"
+ "time"
msession "github.com/go-macaron/session"
"github.com/grafana/grafana/pkg/bus"
@@ -146,17 +147,95 @@ func TestMiddlewareContext(t *testing.T) {
})
})
- middlewareScenario("Auth token service", func(sc *scenarioContext) {
- var wasCalled bool
- sc.userAuthTokenService.initContextWithTokenProvider = func(ctx *m.ReqContext, orgId int64) bool {
- wasCalled = true
- return false
+ middlewareScenario("Non-expired auth token in cookie which not are being rotated", func(sc *scenarioContext) {
+ sc.withTokenSessionCookie("token")
+
+ bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error {
+ query.Result = &m.SignedInUser{OrgId: 2, UserId: 12}
+ return nil
+ })
+
+ sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) {
+ return &m.UserToken{
+ UserId: 12,
+ UnhashedToken: unhashedToken,
+ }, nil
}
sc.fakeReq("GET", "/").exec()
- Convey("should call middleware", func() {
- So(wasCalled, ShouldBeTrue)
+ Convey("should init context with user info", func() {
+ So(sc.context.IsSignedIn, ShouldBeTrue)
+ So(sc.context.UserId, ShouldEqual, 12)
+ So(sc.context.UserToken.UserId, ShouldEqual, 12)
+ So(sc.context.UserToken.UnhashedToken, ShouldEqual, "token")
+ })
+
+ Convey("should not set cookie", func() {
+ So(sc.resp.Header().Get("Set-Cookie"), ShouldEqual, "")
+ })
+ })
+
+ middlewareScenario("Non-expired auth token in cookie which are being rotated", func(sc *scenarioContext) {
+ sc.withTokenSessionCookie("token")
+
+ bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error {
+ query.Result = &m.SignedInUser{OrgId: 2, UserId: 12}
+ return nil
+ })
+
+ sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) {
+ return &m.UserToken{
+ UserId: 12,
+ UnhashedToken: "",
+ }, nil
+ }
+
+ sc.userAuthTokenService.tryRotateTokenProvider = func(userToken *m.UserToken, clientIP, userAgent string) (bool, error) {
+ userToken.UnhashedToken = "rotated"
+ return true, nil
+ }
+
+ maxAgeHours := (time.Duration(setting.LoginMaxLifetimeDays) * 24 * time.Hour)
+ maxAge := (maxAgeHours + time.Hour).Seconds()
+
+ expectedCookie := &http.Cookie{
+ Name: setting.LoginCookieName,
+ Value: "rotated",
+ Path: setting.AppSubUrl + "/",
+ HttpOnly: true,
+ MaxAge: int(maxAge),
+ Secure: setting.CookieSecure,
+ SameSite: setting.CookieSameSite,
+ }
+
+ sc.fakeReq("GET", "/").exec()
+
+ Convey("should init context with user info", func() {
+ So(sc.context.IsSignedIn, ShouldBeTrue)
+ So(sc.context.UserId, ShouldEqual, 12)
+ So(sc.context.UserToken.UserId, ShouldEqual, 12)
+ So(sc.context.UserToken.UnhashedToken, ShouldEqual, "rotated")
+ })
+
+ Convey("should set cookie", func() {
+ So(sc.resp.Header().Get("Set-Cookie"), ShouldEqual, expectedCookie.String())
+ })
+ })
+
+ middlewareScenario("Invalid/expired auth token in cookie", func(sc *scenarioContext) {
+ sc.withTokenSessionCookie("token")
+
+ sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) {
+ return nil, m.ErrUserTokenNotFound
+ }
+
+ sc.fakeReq("GET", "/").exec()
+
+ Convey("should not init context with user info", func() {
+ So(sc.context.IsSignedIn, ShouldBeFalse)
+ So(sc.context.UserId, ShouldEqual, 0)
+ So(sc.context.UserToken, ShouldBeNil)
})
})
@@ -469,6 +548,9 @@ func middlewareScenario(desc string, fn scenarioFunc) {
Convey(desc, func() {
defer bus.ClearBusHandlers()
+ setting.LoginCookieName = "grafana_session"
+ setting.LoginMaxLifetimeDays = 30
+
sc := &scenarioContext{}
viewsPath, _ := filepath.Abs("../../public/views")
@@ -508,6 +590,7 @@ type scenarioContext struct {
resp *httptest.ResponseRecorder
apiKey string
authHeader string
+ tokenSessionCookie string
respJson map[string]interface{}
handlerFunc handlerFunc
defaultHandler macaron.Handler
@@ -522,6 +605,11 @@ func (sc *scenarioContext) withValidApiKey() *scenarioContext {
return sc
}
+func (sc *scenarioContext) withTokenSessionCookie(unhashedToken string) *scenarioContext {
+ sc.tokenSessionCookie = unhashedToken
+ return sc
+}
+
func (sc *scenarioContext) withAuthorizationHeader(authHeader string) *scenarioContext {
sc.authHeader = authHeader
return sc
@@ -571,6 +659,13 @@ func (sc *scenarioContext) exec() {
sc.req.Header.Add("Authorization", sc.authHeader)
}
+ if sc.tokenSessionCookie != "" {
+ sc.req.AddCookie(&http.Cookie{
+ Name: setting.LoginCookieName,
+ Value: sc.tokenSessionCookie,
+ })
+ }
+
sc.m.ServeHTTP(sc.resp, sc.req)
if sc.resp.Header().Get("Content-Type") == "application/json; charset=UTF-8" {
@@ -583,23 +678,47 @@ type scenarioFunc func(c *scenarioContext)
type handlerFunc func(c *m.ReqContext)
type fakeUserAuthTokenService struct {
- initContextWithTokenProvider func(ctx *m.ReqContext, orgID int64) bool
+ createTokenProvider func(userId int64, clientIP, userAgent string) (*m.UserToken, error)
+ tryRotateTokenProvider func(token *m.UserToken, clientIP, userAgent string) (bool, error)
+ lookupTokenProvider func(unhashedToken string) (*m.UserToken, error)
+ revokeTokenProvider func(token *m.UserToken) error
}
func newFakeUserAuthTokenService() *fakeUserAuthTokenService {
return &fakeUserAuthTokenService{
- initContextWithTokenProvider: func(ctx *m.ReqContext, orgID int64) bool {
- return false
+ createTokenProvider: func(userId int64, clientIP, userAgent string) (*m.UserToken, error) {
+ return &m.UserToken{
+ UserId: 0,
+ UnhashedToken: "",
+ }, nil
+ },
+ tryRotateTokenProvider: func(token *m.UserToken, clientIP, userAgent string) (bool, error) {
+ return false, nil
+ },
+ lookupTokenProvider: func(unhashedToken string) (*m.UserToken, error) {
+ return &m.UserToken{
+ UserId: 0,
+ UnhashedToken: "",
+ }, nil
+ },
+ revokeTokenProvider: func(token *m.UserToken) error {
+ return nil
},
}
}
-func (s *fakeUserAuthTokenService) InitContextWithToken(ctx *m.ReqContext, orgID int64) bool {
- return s.initContextWithTokenProvider(ctx, orgID)
+func (s *fakeUserAuthTokenService) CreateToken(userId int64, clientIP, userAgent string) (*m.UserToken, error) {
+ return s.createTokenProvider(userId, clientIP, userAgent)
}
-func (s *fakeUserAuthTokenService) UserAuthenticatedHook(user *m.User, c *m.ReqContext) error {
- return nil
+func (s *fakeUserAuthTokenService) LookupToken(unhashedToken string) (*m.UserToken, error) {
+ return s.lookupTokenProvider(unhashedToken)
}
-func (s *fakeUserAuthTokenService) SignOutUser(c *m.ReqContext) error { return nil }
+func (s *fakeUserAuthTokenService) TryRotateToken(token *m.UserToken, clientIP, userAgent string) (bool, error) {
+ return s.tryRotateTokenProvider(token, clientIP, userAgent)
+}
+
+func (s *fakeUserAuthTokenService) RevokeToken(token *m.UserToken) error {
+ return s.revokeTokenProvider(token)
+}
diff --git a/pkg/middleware/org_redirect_test.go b/pkg/middleware/org_redirect_test.go
index 46b8776fdcc..e01d1a68d21 100644
--- a/pkg/middleware/org_redirect_test.go
+++ b/pkg/middleware/org_redirect_test.go
@@ -14,14 +14,21 @@ func TestOrgRedirectMiddleware(t *testing.T) {
Convey("Can redirect to correct org", t, func() {
middlewareScenario("when setting a correct org for the user", func(sc *scenarioContext) {
+ sc.withTokenSessionCookie("token")
bus.AddHandler("test", func(query *m.SetUsingOrgCommand) error {
return nil
})
- sc.userAuthTokenService.initContextWithTokenProvider = func(ctx *m.ReqContext, orgId int64) bool {
- ctx.SignedInUser = &m.SignedInUser{OrgId: 1, UserId: 12}
- ctx.IsSignedIn = true
- return true
+ bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error {
+ query.Result = &m.SignedInUser{OrgId: 1, UserId: 12}
+ return nil
+ })
+
+ sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) {
+ return &m.UserToken{
+ UserId: 0,
+ UnhashedToken: "",
+ }, nil
}
sc.m.Get("/", sc.defaultHandler)
@@ -33,21 +40,23 @@ func TestOrgRedirectMiddleware(t *testing.T) {
})
middlewareScenario("when setting an invalid org for user", func(sc *scenarioContext) {
+ sc.withTokenSessionCookie("token")
bus.AddHandler("test", func(query *m.SetUsingOrgCommand) error {
return fmt.Errorf("")
})
- sc.userAuthTokenService.initContextWithTokenProvider = func(ctx *m.ReqContext, orgId int64) bool {
- ctx.SignedInUser = &m.SignedInUser{OrgId: 1, UserId: 12}
- ctx.IsSignedIn = true
- return true
- }
-
bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error {
query.Result = &m.SignedInUser{OrgId: 1, UserId: 12}
return nil
})
+ sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) {
+ return &m.UserToken{
+ UserId: 12,
+ UnhashedToken: "",
+ }, nil
+ }
+
sc.m.Get("/", sc.defaultHandler)
sc.fakeReq("GET", "/?orgId=3").exec()
diff --git a/pkg/middleware/quota_test.go b/pkg/middleware/quota_test.go
index 4f2203a5d3d..e2a6ef63377 100644
--- a/pkg/middleware/quota_test.go
+++ b/pkg/middleware/quota_test.go
@@ -74,10 +74,17 @@ func TestMiddlewareQuota(t *testing.T) {
})
middlewareScenario("with user logged in", func(sc *scenarioContext) {
- sc.userAuthTokenService.initContextWithTokenProvider = func(ctx *m.ReqContext, orgId int64) bool {
- ctx.SignedInUser = &m.SignedInUser{OrgId: 2, UserId: 12}
- ctx.IsSignedIn = true
- return true
+ sc.withTokenSessionCookie("token")
+ bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error {
+ query.Result = &m.SignedInUser{OrgId: 2, UserId: 12}
+ return nil
+ })
+
+ sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) {
+ return &m.UserToken{
+ UserId: 12,
+ UnhashedToken: "",
+ }, nil
}
bus.AddHandler("globalQuota", func(query *m.GetGlobalQuotaByTargetQuery) error {
diff --git a/pkg/models/context.go b/pkg/models/context.go
index df970451304..b0c6ec9226d 100644
--- a/pkg/models/context.go
+++ b/pkg/models/context.go
@@ -13,6 +13,7 @@ import (
type ReqContext struct {
*macaron.Context
*SignedInUser
+ UserToken *UserToken
// This should only be used by the auth_proxy
Session session.SessionStore
diff --git a/pkg/models/datasource_cache.go b/pkg/models/datasource_cache.go
index 66ba66e4d39..1c895514ace 100644
--- a/pkg/models/datasource_cache.go
+++ b/pkg/models/datasource_cache.go
@@ -46,19 +46,16 @@ func (ds *DataSource) GetHttpTransport() (*http.Transport, error) {
return t.Transport, nil
}
- var tlsSkipVerify, tlsClientAuth, tlsAuthWithCACert bool
- if ds.JsonData != nil {
- tlsClientAuth = ds.JsonData.Get("tlsAuth").MustBool(false)
- tlsAuthWithCACert = ds.JsonData.Get("tlsAuthWithCACert").MustBool(false)
- tlsSkipVerify = ds.JsonData.Get("tlsSkipVerify").MustBool(false)
+ tlsConfig, err := ds.GetTLSConfig()
+ if err != nil {
+ return nil, err
}
+ tlsConfig.Renegotiation = tls.RenegotiateFreelyAsClient
+
transport := &http.Transport{
- TLSClientConfig: &tls.Config{
- InsecureSkipVerify: tlsSkipVerify,
- Renegotiation: tls.RenegotiateFreelyAsClient,
- },
- Proxy: http.ProxyFromEnvironment,
+ TLSClientConfig: tlsConfig,
+ Proxy: http.ProxyFromEnvironment,
Dial: (&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
@@ -70,6 +67,26 @@ func (ds *DataSource) GetHttpTransport() (*http.Transport, error) {
IdleConnTimeout: 90 * time.Second,
}
+ ptc.cache[ds.Id] = cachedTransport{
+ Transport: transport,
+ updated: ds.Updated,
+ }
+
+ return transport, nil
+}
+
+func (ds *DataSource) GetTLSConfig() (*tls.Config, error) {
+ var tlsSkipVerify, tlsClientAuth, tlsAuthWithCACert bool
+ if ds.JsonData != nil {
+ tlsClientAuth = ds.JsonData.Get("tlsAuth").MustBool(false)
+ tlsAuthWithCACert = ds.JsonData.Get("tlsAuthWithCACert").MustBool(false)
+ tlsSkipVerify = ds.JsonData.Get("tlsSkipVerify").MustBool(false)
+ }
+
+ tlsConfig := &tls.Config{
+ InsecureSkipVerify: tlsSkipVerify,
+ }
+
if tlsClientAuth || tlsAuthWithCACert {
decrypted := ds.SecureJsonData.Decrypt()
if tlsAuthWithCACert && len(decrypted["tlsCACert"]) > 0 {
@@ -78,7 +95,7 @@ func (ds *DataSource) GetHttpTransport() (*http.Transport, error) {
if !ok {
return nil, errors.New("Failed to parse TLS CA PEM certificate")
}
- transport.TLSClientConfig.RootCAs = caPool
+ tlsConfig.RootCAs = caPool
}
if tlsClientAuth {
@@ -86,14 +103,9 @@ func (ds *DataSource) GetHttpTransport() (*http.Transport, error) {
if err != nil {
return nil, err
}
- transport.TLSClientConfig.Certificates = []tls.Certificate{cert}
+ tlsConfig.Certificates = []tls.Certificate{cert}
}
}
- ptc.cache[ds.Id] = cachedTransport{
- Transport: transport,
- updated: ds.Updated,
- }
-
- return transport, nil
+ return tlsConfig, nil
}
diff --git a/pkg/models/user_token.go b/pkg/models/user_token.go
new file mode 100644
index 00000000000..c8084cf1eba
--- /dev/null
+++ b/pkg/models/user_token.go
@@ -0,0 +1,32 @@
+package models
+
+import "errors"
+
+// Typed errors
+var (
+ ErrUserTokenNotFound = errors.New("user token not found")
+)
+
+// UserToken represents a user token
+type UserToken struct {
+ Id int64
+ UserId int64
+ AuthToken string
+ PrevAuthToken string
+ UserAgent string
+ ClientIp string
+ AuthTokenSeen bool
+ SeenAt int64
+ RotatedAt int64
+ CreatedAt int64
+ UpdatedAt int64
+ UnhashedToken string
+}
+
+// UserTokenService are used for generating and validating user tokens
+type UserTokenService interface {
+ CreateToken(userId int64, clientIP, userAgent string) (*UserToken, error)
+ LookupToken(unhashedToken string) (*UserToken, error)
+ TryRotateToken(token *UserToken, clientIP, userAgent string) (bool, error)
+ RevokeToken(token *UserToken) error
+}
diff --git a/pkg/services/auth/auth_token.go b/pkg/services/auth/auth_token.go
index 13b9ef607f5..ef5dccd779f 100644
--- a/pkg/services/auth/auth_token.go
+++ b/pkg/services/auth/auth_token.go
@@ -3,13 +3,10 @@ package auth
import (
"crypto/sha256"
"encoding/hex"
- "errors"
- "net/http"
- "net/url"
"time"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/infra/serverlock"
+
"github.com/grafana/grafana/pkg/log"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/registry"
@@ -19,116 +16,26 @@ import (
)
func init() {
- registry.RegisterService(&UserAuthTokenServiceImpl{})
+ registry.RegisterService(&UserAuthTokenService{})
}
-var (
- getTime = time.Now
- UrgentRotateTime = 1 * time.Minute
- oneYearInSeconds = 31557600 //used as default maxage for session cookies. We validate/rotate them more often.
-)
+var getTime = time.Now
-// UserAuthTokenService are used for generating and validating user auth tokens
-type UserAuthTokenService interface {
- InitContextWithToken(ctx *models.ReqContext, orgID int64) bool
- UserAuthenticatedHook(user *models.User, c *models.ReqContext) error
- SignOutUser(c *models.ReqContext) error
-}
+const urgentRotateTime = 1 * time.Minute
-type UserAuthTokenServiceImpl struct {
+type UserAuthTokenService struct {
SQLStore *sqlstore.SqlStore `inject:""`
ServerLockService *serverlock.ServerLockService `inject:""`
Cfg *setting.Cfg `inject:""`
log log.Logger
}
-// Init this service
-func (s *UserAuthTokenServiceImpl) Init() error {
+func (s *UserAuthTokenService) Init() error {
s.log = log.New("auth")
return nil
}
-func (s *UserAuthTokenServiceImpl) InitContextWithToken(ctx *models.ReqContext, orgID int64) bool {
- //auth User
- unhashedToken := ctx.GetCookie(s.Cfg.LoginCookieName)
- if unhashedToken == "" {
- return false
- }
-
- userToken, err := s.LookupToken(unhashedToken)
- if err != nil {
- ctx.Logger.Info("failed to look up user based on cookie", "error", err)
- return false
- }
-
- query := models.GetSignedInUserQuery{UserId: userToken.UserId, OrgId: orgID}
- if err := bus.Dispatch(&query); err != nil {
- ctx.Logger.Error("Failed to get user with id", "userId", userToken.UserId, "error", err)
- return false
- }
-
- ctx.SignedInUser = query.Result
- ctx.IsSignedIn = true
-
- //rotate session token if needed.
- rotated, err := s.RefreshToken(userToken, ctx.RemoteAddr(), ctx.Req.UserAgent())
- if err != nil {
- ctx.Logger.Error("failed to rotate token", "error", err, "userId", userToken.UserId, "tokenId", userToken.Id)
- return true
- }
-
- if rotated {
- s.writeSessionCookie(ctx, userToken.UnhashedToken, oneYearInSeconds)
- }
-
- return true
-}
-
-func (s *UserAuthTokenServiceImpl) writeSessionCookie(ctx *models.ReqContext, value string, maxAge int) {
- if setting.Env == setting.DEV {
- ctx.Logger.Debug("new token", "unhashed token", value)
- }
-
- ctx.Resp.Header().Del("Set-Cookie")
- cookie := http.Cookie{
- Name: s.Cfg.LoginCookieName,
- Value: url.QueryEscape(value),
- HttpOnly: true,
- Path: setting.AppSubUrl + "/",
- Secure: s.Cfg.SecurityHTTPSCookies,
- MaxAge: maxAge,
- SameSite: s.Cfg.LoginCookieSameSite,
- }
-
- http.SetCookie(ctx.Resp, &cookie)
-}
-
-func (s *UserAuthTokenServiceImpl) UserAuthenticatedHook(user *models.User, c *models.ReqContext) error {
- userToken, err := s.CreateToken(user.Id, c.RemoteAddr(), c.Req.UserAgent())
- if err != nil {
- return err
- }
-
- s.writeSessionCookie(c, userToken.UnhashedToken, oneYearInSeconds)
- return nil
-}
-
-func (s *UserAuthTokenServiceImpl) SignOutUser(c *models.ReqContext) error {
- unhashedToken := c.GetCookie(s.Cfg.LoginCookieName)
- if unhashedToken == "" {
- return errors.New("cannot logout without session token")
- }
-
- hashedToken := hashToken(unhashedToken)
-
- sql := `DELETE FROM user_auth_token WHERE auth_token = ?`
- _, err := s.SQLStore.NewSession().Exec(sql, hashedToken)
-
- s.writeSessionCookie(c, "", -1)
- return err
-}
-
-func (s *UserAuthTokenServiceImpl) CreateToken(userId int64, clientIP, userAgent string) (*userAuthToken, error) {
+func (s *UserAuthTokenService) CreateToken(userId int64, clientIP, userAgent string) (*models.UserToken, error) {
clientIP = util.ParseIPAddress(clientIP)
token, err := util.RandomHex(16)
if err != nil {
@@ -139,7 +46,7 @@ func (s *UserAuthTokenServiceImpl) CreateToken(userId int64, clientIP, userAgent
now := getTime().Unix()
- userToken := userAuthToken{
+ userAuthToken := userAuthToken{
UserId: userId,
AuthToken: hashedToken,
PrevAuthToken: hashedToken,
@@ -151,98 +58,114 @@ func (s *UserAuthTokenServiceImpl) CreateToken(userId int64, clientIP, userAgent
SeenAt: 0,
AuthTokenSeen: false,
}
- _, err = s.SQLStore.NewSession().Insert(&userToken)
+ _, err = s.SQLStore.NewSession().Insert(&userAuthToken)
if err != nil {
return nil, err
}
- userToken.UnhashedToken = token
+ userAuthToken.UnhashedToken = token
- return &userToken, nil
+ s.log.Debug("user auth token created", "tokenId", userAuthToken.Id, "userId", userAuthToken.UserId, "clientIP", userAuthToken.ClientIp, "userAgent", userAuthToken.UserAgent, "authToken", userAuthToken.AuthToken)
+
+ var userToken models.UserToken
+ err = userAuthToken.toUserToken(&userToken)
+
+ return &userToken, err
}
-func (s *UserAuthTokenServiceImpl) LookupToken(unhashedToken string) (*userAuthToken, error) {
+func (s *UserAuthTokenService) LookupToken(unhashedToken string) (*models.UserToken, error) {
hashedToken := hashToken(unhashedToken)
if setting.Env == setting.DEV {
s.log.Debug("looking up token", "unhashed", unhashedToken, "hashed", hashedToken)
}
- expireBefore := getTime().Add(time.Duration(-86400*s.Cfg.LoginCookieMaxDays) * time.Second).Unix()
+ tokenMaxLifetime := time.Duration(s.Cfg.LoginMaxLifetimeDays) * 24 * time.Hour
+ tokenMaxInactiveLifetime := time.Duration(s.Cfg.LoginMaxInactiveLifetimeDays) * 24 * time.Hour
+ createdAfter := getTime().Add(-tokenMaxLifetime).Unix()
+ rotatedAfter := getTime().Add(-tokenMaxInactiveLifetime).Unix()
- var userToken userAuthToken
- exists, err := s.SQLStore.NewSession().Where("(auth_token = ? OR prev_auth_token = ?) AND created_at > ?", hashedToken, hashedToken, expireBefore).Get(&userToken)
+ var model userAuthToken
+ exists, err := s.SQLStore.NewSession().Where("(auth_token = ? OR prev_auth_token = ?) AND created_at > ? AND rotated_at > ?", hashedToken, hashedToken, createdAfter, rotatedAfter).Get(&model)
if err != nil {
return nil, err
}
if !exists {
- return nil, ErrAuthTokenNotFound
+ return nil, models.ErrUserTokenNotFound
}
- if userToken.AuthToken != hashedToken && userToken.PrevAuthToken == hashedToken && userToken.AuthTokenSeen {
- userTokenCopy := userToken
- userTokenCopy.AuthTokenSeen = false
- expireBefore := getTime().Add(-UrgentRotateTime).Unix()
- affectedRows, err := s.SQLStore.NewSession().Where("id = ? AND prev_auth_token = ? AND rotated_at < ?", userTokenCopy.Id, userTokenCopy.PrevAuthToken, expireBefore).AllCols().Update(&userTokenCopy)
+ if model.AuthToken != hashedToken && model.PrevAuthToken == hashedToken && model.AuthTokenSeen {
+ modelCopy := model
+ modelCopy.AuthTokenSeen = false
+ expireBefore := getTime().Add(-urgentRotateTime).Unix()
+ affectedRows, err := s.SQLStore.NewSession().Where("id = ? AND prev_auth_token = ? AND rotated_at < ?", modelCopy.Id, modelCopy.PrevAuthToken, expireBefore).AllCols().Update(&modelCopy)
if err != nil {
return nil, err
}
if affectedRows == 0 {
- s.log.Debug("prev seen token unchanged", "userTokenId", userToken.Id, "userId", userToken.UserId, "authToken", userToken.AuthToken, "clientIP", userToken.ClientIp, "userAgent", userToken.UserAgent)
+ s.log.Debug("prev seen token unchanged", "tokenId", model.Id, "userId", model.UserId, "clientIP", model.ClientIp, "userAgent", model.UserAgent, "authToken", model.AuthToken)
} else {
- s.log.Debug("prev seen token", "userTokenId", userToken.Id, "userId", userToken.UserId, "authToken", userToken.AuthToken, "clientIP", userToken.ClientIp, "userAgent", userToken.UserAgent)
+ s.log.Debug("prev seen token", "tokenId", model.Id, "userId", model.UserId, "clientIP", model.ClientIp, "userAgent", model.UserAgent, "authToken", model.AuthToken)
}
}
- if !userToken.AuthTokenSeen && userToken.AuthToken == hashedToken {
- userTokenCopy := userToken
- userTokenCopy.AuthTokenSeen = true
- userTokenCopy.SeenAt = getTime().Unix()
- affectedRows, err := s.SQLStore.NewSession().Where("id = ? AND auth_token = ?", userTokenCopy.Id, userTokenCopy.AuthToken).AllCols().Update(&userTokenCopy)
+ if !model.AuthTokenSeen && model.AuthToken == hashedToken {
+ modelCopy := model
+ modelCopy.AuthTokenSeen = true
+ modelCopy.SeenAt = getTime().Unix()
+ affectedRows, err := s.SQLStore.NewSession().Where("id = ? AND auth_token = ?", modelCopy.Id, modelCopy.AuthToken).AllCols().Update(&modelCopy)
if err != nil {
return nil, err
}
if affectedRows == 1 {
- userToken = userTokenCopy
+ model = modelCopy
}
if affectedRows == 0 {
- s.log.Debug("seen wrong token", "userTokenId", userToken.Id, "userId", userToken.UserId, "authToken", userToken.AuthToken, "clientIP", userToken.ClientIp, "userAgent", userToken.UserAgent)
+ s.log.Debug("seen wrong token", "tokenId", model.Id, "userId", model.UserId, "clientIP", model.ClientIp, "userAgent", model.UserAgent, "authToken", model.AuthToken)
} else {
- s.log.Debug("seen token", "userTokenId", userToken.Id, "userId", userToken.UserId, "authToken", userToken.AuthToken, "clientIP", userToken.ClientIp, "userAgent", userToken.UserAgent)
+ s.log.Debug("seen token", "tokenId", model.Id, "userId", model.UserId, "clientIP", model.ClientIp, "userAgent", model.UserAgent, "authToken", model.AuthToken)
}
}
- userToken.UnhashedToken = unhashedToken
+ model.UnhashedToken = unhashedToken
- return &userToken, nil
+ var userToken models.UserToken
+ err = model.toUserToken(&userToken)
+
+ return &userToken, err
}
-func (s *UserAuthTokenServiceImpl) RefreshToken(token *userAuthToken, clientIP, userAgent string) (bool, error) {
+func (s *UserAuthTokenService) TryRotateToken(token *models.UserToken, clientIP, userAgent string) (bool, error) {
if token == nil {
return false, nil
}
+ model := userAuthTokenFromUserToken(token)
+
now := getTime()
needsRotation := false
- rotatedAt := time.Unix(token.RotatedAt, 0)
- if token.AuthTokenSeen {
- needsRotation = rotatedAt.Before(now.Add(-time.Duration(s.Cfg.LoginCookieRotation) * time.Minute))
+ rotatedAt := time.Unix(model.RotatedAt, 0)
+ if model.AuthTokenSeen {
+ needsRotation = rotatedAt.Before(now.Add(-time.Duration(s.Cfg.TokenRotationIntervalMinutes) * time.Minute))
} else {
- needsRotation = rotatedAt.Before(now.Add(-UrgentRotateTime))
+ needsRotation = rotatedAt.Before(now.Add(-urgentRotateTime))
}
if !needsRotation {
return false, nil
}
- s.log.Debug("refresh token needs rotation?", "auth_token_seen", token.AuthTokenSeen, "rotated_at", rotatedAt, "token.Id", token.Id)
+ s.log.Debug("token needs rotation", "tokenId", model.Id, "authTokenSeen", model.AuthTokenSeen, "rotatedAt", rotatedAt)
clientIP = util.ParseIPAddress(clientIP)
- newToken, _ := util.RandomHex(16)
+ newToken, err := util.RandomHex(16)
+ if err != nil {
+ return false, err
+ }
hashedToken := hashToken(newToken)
// very important that auth_token_seen is set after the prev_auth_token = case when ... for mysql to function correctly
@@ -258,21 +181,44 @@ func (s *UserAuthTokenServiceImpl) RefreshToken(token *userAuthToken, clientIP,
rotated_at = ?
WHERE id = ? AND (auth_token_seen = ? OR rotated_at < ?)`
- res, err := s.SQLStore.NewSession().Exec(sql, userAgent, clientIP, s.SQLStore.Dialect.BooleanStr(true), hashedToken, s.SQLStore.Dialect.BooleanStr(false), now.Unix(), token.Id, s.SQLStore.Dialect.BooleanStr(true), now.Add(-30*time.Second).Unix())
+ res, err := s.SQLStore.NewSession().Exec(sql, userAgent, clientIP, s.SQLStore.Dialect.BooleanStr(true), hashedToken, s.SQLStore.Dialect.BooleanStr(false), now.Unix(), model.Id, s.SQLStore.Dialect.BooleanStr(true), now.Add(-30*time.Second).Unix())
if err != nil {
return false, err
}
affected, _ := res.RowsAffected()
- s.log.Debug("rotated", "affected", affected, "auth_token_id", token.Id, "userId", token.UserId)
+ s.log.Debug("auth token rotated", "affected", affected, "auth_token_id", model.Id, "userId", model.UserId)
if affected > 0 {
- token.UnhashedToken = newToken
+ model.UnhashedToken = newToken
+ model.toUserToken(token)
return true, nil
}
return false, nil
}
+func (s *UserAuthTokenService) RevokeToken(token *models.UserToken) error {
+ if token == nil {
+ return models.ErrUserTokenNotFound
+ }
+
+ model := userAuthTokenFromUserToken(token)
+
+ rowsAffected, err := s.SQLStore.NewSession().Delete(model)
+ if err != nil {
+ return err
+ }
+
+ if rowsAffected == 0 {
+ s.log.Debug("user auth token not found/revoked", "tokenId", model.Id, "userId", model.UserId, "clientIP", model.ClientIp, "userAgent", model.UserAgent)
+ return models.ErrUserTokenNotFound
+ }
+
+ s.log.Debug("user auth token revoked", "tokenId", model.Id, "userId", model.UserId, "clientIP", model.ClientIp, "userAgent", model.UserAgent)
+
+ return nil
+}
+
func hashToken(token string) string {
hashBytes := sha256.Sum256([]byte(token + setting.SecretKey))
return hex.EncodeToString(hashBytes[:])
diff --git a/pkg/services/auth/auth_token_test.go b/pkg/services/auth/auth_token_test.go
index 312e53a3970..26dcbc5c868 100644
--- a/pkg/services/auth/auth_token_test.go
+++ b/pkg/services/auth/auth_token_test.go
@@ -1,17 +1,15 @@
package auth
import (
- "fmt"
- "net/http"
- "net/http/httptest"
+ "encoding/json"
"testing"
"time"
- "github.com/grafana/grafana/pkg/models"
+ "github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/setting"
- macaron "gopkg.in/macaron.v1"
"github.com/grafana/grafana/pkg/log"
+ "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/sqlstore"
. "github.com/smartystreets/goconvey/convey"
)
@@ -28,236 +26,265 @@ func TestUserAuthToken(t *testing.T) {
}
Convey("When creating token", func() {
- token, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent")
+ userToken, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent")
So(err, ShouldBeNil)
- So(token, ShouldNotBeNil)
- So(token.AuthTokenSeen, ShouldBeFalse)
+ So(userToken, ShouldNotBeNil)
+ So(userToken.AuthTokenSeen, ShouldBeFalse)
Convey("When lookup unhashed token should return user auth token", func() {
- LookupToken, err := userAuthTokenService.LookupToken(token.UnhashedToken)
+ userToken, err := userAuthTokenService.LookupToken(userToken.UnhashedToken)
So(err, ShouldBeNil)
- So(LookupToken, ShouldNotBeNil)
- So(LookupToken.UserId, ShouldEqual, userID)
- So(LookupToken.AuthTokenSeen, ShouldBeTrue)
+ So(userToken, ShouldNotBeNil)
+ So(userToken.UserId, ShouldEqual, userID)
+ So(userToken.AuthTokenSeen, ShouldBeTrue)
- storedAuthToken, err := ctx.getAuthTokenByID(LookupToken.Id)
+ storedAuthToken, err := ctx.getAuthTokenByID(userToken.Id)
So(err, ShouldBeNil)
So(storedAuthToken, ShouldNotBeNil)
So(storedAuthToken.AuthTokenSeen, ShouldBeTrue)
})
Convey("When lookup hashed token should return user auth token not found error", func() {
- LookupToken, err := userAuthTokenService.LookupToken(token.AuthToken)
- So(err, ShouldEqual, ErrAuthTokenNotFound)
- So(LookupToken, ShouldBeNil)
+ userToken, err := userAuthTokenService.LookupToken(userToken.AuthToken)
+ So(err, ShouldEqual, models.ErrUserTokenNotFound)
+ So(userToken, ShouldBeNil)
})
- Convey("signing out should delete token and cookie if present", func() {
- httpreq := &http.Request{Header: make(http.Header)}
- httpreq.AddCookie(&http.Cookie{Name: userAuthTokenService.Cfg.LoginCookieName, Value: token.UnhashedToken})
-
- ctx := &models.ReqContext{Context: &macaron.Context{
- Req: macaron.Request{Request: httpreq},
- Resp: macaron.NewResponseWriter("POST", httptest.NewRecorder()),
- },
- Logger: log.New("fakelogger"),
- }
-
- err = userAuthTokenService.SignOutUser(ctx)
+ Convey("revoking existing token should delete token", func() {
+ err = userAuthTokenService.RevokeToken(userToken)
So(err, ShouldBeNil)
- // makes sure we tell the browser to overwrite the cookie
- cookieHeader := fmt.Sprintf("%s=; Path=/; Max-Age=0; HttpOnly", userAuthTokenService.Cfg.LoginCookieName)
- So(ctx.Resp.Header().Get("Set-Cookie"), ShouldEqual, cookieHeader)
+ model, err := ctx.getAuthTokenByID(userToken.Id)
+ So(err, ShouldBeNil)
+ So(model, ShouldBeNil)
})
- Convey("signing out an none existing session should return an error", func() {
- httpreq := &http.Request{Header: make(http.Header)}
- httpreq.AddCookie(&http.Cookie{Name: userAuthTokenService.Cfg.LoginCookieName, Value: ""})
+ Convey("revoking nil token should return error", func() {
+ err = userAuthTokenService.RevokeToken(nil)
+ So(err, ShouldEqual, models.ErrUserTokenNotFound)
+ })
- ctx := &models.ReqContext{Context: &macaron.Context{
- Req: macaron.Request{Request: httpreq},
- Resp: macaron.NewResponseWriter("POST", httptest.NewRecorder()),
- },
- Logger: log.New("fakelogger"),
- }
-
- err = userAuthTokenService.SignOutUser(ctx)
- So(err, ShouldNotBeNil)
+ Convey("revoking non-existing token should return error", func() {
+ userToken.Id = 1000
+ err = userAuthTokenService.RevokeToken(userToken)
+ So(err, ShouldEqual, models.ErrUserTokenNotFound)
})
})
Convey("expires correctly", func() {
- token, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent")
- So(err, ShouldBeNil)
- So(token, ShouldNotBeNil)
-
- _, err = userAuthTokenService.LookupToken(token.UnhashedToken)
+ userToken, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent")
So(err, ShouldBeNil)
- token, err = ctx.getAuthTokenByID(token.Id)
+ userToken, err = userAuthTokenService.LookupToken(userToken.UnhashedToken)
So(err, ShouldBeNil)
getTime = func() time.Time {
return t.Add(time.Hour)
}
- refreshed, err := userAuthTokenService.RefreshToken(token, "192.168.10.11:1234", "some user agent")
+ rotated, err := userAuthTokenService.TryRotateToken(userToken, "192.168.10.11:1234", "some user agent")
So(err, ShouldBeNil)
- So(refreshed, ShouldBeTrue)
+ So(rotated, ShouldBeTrue)
- _, err = userAuthTokenService.LookupToken(token.UnhashedToken)
+ userToken, err = userAuthTokenService.LookupToken(userToken.UnhashedToken)
So(err, ShouldBeNil)
- stillGood, err := userAuthTokenService.LookupToken(token.UnhashedToken)
+ stillGood, err := userAuthTokenService.LookupToken(userToken.UnhashedToken)
So(err, ShouldBeNil)
So(stillGood, ShouldNotBeNil)
- getTime = func() time.Time {
- return t.Add(24 * 7 * time.Hour)
- }
- notGood, err := userAuthTokenService.LookupToken(token.UnhashedToken)
- So(err, ShouldEqual, ErrAuthTokenNotFound)
- So(notGood, ShouldBeNil)
+ model, err := ctx.getAuthTokenByID(userToken.Id)
+ So(err, ShouldBeNil)
+
+ Convey("when rotated_at is 6:59:59 ago should find token", func() {
+ getTime = func() time.Time {
+ return time.Unix(model.RotatedAt, 0).Add(24 * 7 * time.Hour).Add(-time.Second)
+ }
+
+ stillGood, err = userAuthTokenService.LookupToken(stillGood.UnhashedToken)
+ So(err, ShouldBeNil)
+ So(stillGood, ShouldNotBeNil)
+ })
+
+ Convey("when rotated_at is 7:00:00 ago should not find token", func() {
+ getTime = func() time.Time {
+ return time.Unix(model.RotatedAt, 0).Add(24 * 7 * time.Hour)
+ }
+
+ notGood, err := userAuthTokenService.LookupToken(userToken.UnhashedToken)
+ So(err, ShouldEqual, models.ErrUserTokenNotFound)
+ So(notGood, ShouldBeNil)
+ })
+
+ Convey("when rotated_at is 5 days ago and created_at is 29 days and 23:59:59 ago should not find token", func() {
+ updated, err := ctx.updateRotatedAt(model.Id, time.Unix(model.CreatedAt, 0).Add(24*25*time.Hour).Unix())
+ So(err, ShouldBeNil)
+ So(updated, ShouldBeTrue)
+
+ getTime = func() time.Time {
+ return time.Unix(model.CreatedAt, 0).Add(24 * 30 * time.Hour).Add(-time.Second)
+ }
+
+ stillGood, err = userAuthTokenService.LookupToken(stillGood.UnhashedToken)
+ So(err, ShouldBeNil)
+ So(stillGood, ShouldNotBeNil)
+ })
+
+ Convey("when rotated_at is 5 days ago and created_at is 30 days ago should not find token", func() {
+ updated, err := ctx.updateRotatedAt(model.Id, time.Unix(model.CreatedAt, 0).Add(24*25*time.Hour).Unix())
+ So(err, ShouldBeNil)
+ So(updated, ShouldBeTrue)
+
+ getTime = func() time.Time {
+ return time.Unix(model.CreatedAt, 0).Add(24 * 30 * time.Hour)
+ }
+
+ notGood, err := userAuthTokenService.LookupToken(userToken.UnhashedToken)
+ So(err, ShouldEqual, models.ErrUserTokenNotFound)
+ So(notGood, ShouldBeNil)
+ })
})
Convey("can properly rotate tokens", func() {
- token, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent")
+ userToken, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent")
So(err, ShouldBeNil)
- So(token, ShouldNotBeNil)
- prevToken := token.AuthToken
- unhashedPrev := token.UnhashedToken
+ prevToken := userToken.AuthToken
+ unhashedPrev := userToken.UnhashedToken
- refreshed, err := userAuthTokenService.RefreshToken(token, "192.168.10.12:1234", "a new user agent")
+ rotated, err := userAuthTokenService.TryRotateToken(userToken, "192.168.10.12:1234", "a new user agent")
So(err, ShouldBeNil)
- So(refreshed, ShouldBeFalse)
+ So(rotated, ShouldBeFalse)
- updated, err := ctx.markAuthTokenAsSeen(token.Id)
+ updated, err := ctx.markAuthTokenAsSeen(userToken.Id)
So(err, ShouldBeNil)
So(updated, ShouldBeTrue)
- token, err = ctx.getAuthTokenByID(token.Id)
+ model, err := ctx.getAuthTokenByID(userToken.Id)
+ So(err, ShouldBeNil)
+
+ var tok models.UserToken
+ err = model.toUserToken(&tok)
So(err, ShouldBeNil)
getTime = func() time.Time {
return t.Add(time.Hour)
}
- refreshed, err = userAuthTokenService.RefreshToken(token, "192.168.10.12:1234", "a new user agent")
+ rotated, err = userAuthTokenService.TryRotateToken(&tok, "192.168.10.12:1234", "a new user agent")
So(err, ShouldBeNil)
- So(refreshed, ShouldBeTrue)
+ So(rotated, ShouldBeTrue)
- unhashedToken := token.UnhashedToken
+ unhashedToken := tok.UnhashedToken
- token, err = ctx.getAuthTokenByID(token.Id)
+ model, err = ctx.getAuthTokenByID(tok.Id)
So(err, ShouldBeNil)
- token.UnhashedToken = unhashedToken
+ model.UnhashedToken = unhashedToken
- So(token.RotatedAt, ShouldEqual, getTime().Unix())
- So(token.ClientIp, ShouldEqual, "192.168.10.12")
- So(token.UserAgent, ShouldEqual, "a new user agent")
- So(token.AuthTokenSeen, ShouldBeFalse)
- So(token.SeenAt, ShouldEqual, 0)
- So(token.PrevAuthToken, ShouldEqual, prevToken)
+ So(model.RotatedAt, ShouldEqual, getTime().Unix())
+ So(model.ClientIp, ShouldEqual, "192.168.10.12")
+ So(model.UserAgent, ShouldEqual, "a new user agent")
+ So(model.AuthTokenSeen, ShouldBeFalse)
+ So(model.SeenAt, ShouldEqual, 0)
+ So(model.PrevAuthToken, ShouldEqual, prevToken)
// ability to auth using an old token
- lookedUp, err := userAuthTokenService.LookupToken(token.UnhashedToken)
+ lookedUpUserToken, err := userAuthTokenService.LookupToken(model.UnhashedToken)
So(err, ShouldBeNil)
- So(lookedUp, ShouldNotBeNil)
- So(lookedUp.AuthTokenSeen, ShouldBeTrue)
- So(lookedUp.SeenAt, ShouldEqual, getTime().Unix())
+ So(lookedUpUserToken, ShouldNotBeNil)
+ So(lookedUpUserToken.AuthTokenSeen, ShouldBeTrue)
+ So(lookedUpUserToken.SeenAt, ShouldEqual, getTime().Unix())
- lookedUp, err = userAuthTokenService.LookupToken(unhashedPrev)
+ lookedUpUserToken, err = userAuthTokenService.LookupToken(unhashedPrev)
So(err, ShouldBeNil)
- So(lookedUp, ShouldNotBeNil)
- So(lookedUp.Id, ShouldEqual, token.Id)
- So(lookedUp.AuthTokenSeen, ShouldBeTrue)
+ So(lookedUpUserToken, ShouldNotBeNil)
+ So(lookedUpUserToken.Id, ShouldEqual, model.Id)
+ So(lookedUpUserToken.AuthTokenSeen, ShouldBeTrue)
getTime = func() time.Time {
return t.Add(time.Hour + (2 * time.Minute))
}
- lookedUp, err = userAuthTokenService.LookupToken(unhashedPrev)
+ lookedUpUserToken, err = userAuthTokenService.LookupToken(unhashedPrev)
So(err, ShouldBeNil)
- So(lookedUp, ShouldNotBeNil)
- So(lookedUp.AuthTokenSeen, ShouldBeTrue)
+ So(lookedUpUserToken, ShouldNotBeNil)
+ So(lookedUpUserToken.AuthTokenSeen, ShouldBeTrue)
- lookedUp, err = ctx.getAuthTokenByID(lookedUp.Id)
+ lookedUpModel, err := ctx.getAuthTokenByID(lookedUpUserToken.Id)
So(err, ShouldBeNil)
- So(lookedUp, ShouldNotBeNil)
- So(lookedUp.AuthTokenSeen, ShouldBeFalse)
+ So(lookedUpModel, ShouldNotBeNil)
+ So(lookedUpModel.AuthTokenSeen, ShouldBeFalse)
- refreshed, err = userAuthTokenService.RefreshToken(token, "192.168.10.12:1234", "a new user agent")
+ rotated, err = userAuthTokenService.TryRotateToken(userToken, "192.168.10.12:1234", "a new user agent")
So(err, ShouldBeNil)
- So(refreshed, ShouldBeTrue)
+ So(rotated, ShouldBeTrue)
- token, err = ctx.getAuthTokenByID(token.Id)
+ model, err = ctx.getAuthTokenByID(userToken.Id)
So(err, ShouldBeNil)
- So(token, ShouldNotBeNil)
- So(token.SeenAt, ShouldEqual, 0)
+ So(model, ShouldNotBeNil)
+ So(model.SeenAt, ShouldEqual, 0)
})
Convey("keeps prev token valid for 1 minute after it is confirmed", func() {
- token, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent")
+ userToken, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent")
So(err, ShouldBeNil)
- So(token, ShouldNotBeNil)
+ So(userToken, ShouldNotBeNil)
- lookedUp, err := userAuthTokenService.LookupToken(token.UnhashedToken)
+ lookedUpUserToken, err := userAuthTokenService.LookupToken(userToken.UnhashedToken)
So(err, ShouldBeNil)
- So(lookedUp, ShouldNotBeNil)
+ So(lookedUpUserToken, ShouldNotBeNil)
getTime = func() time.Time {
return t.Add(10 * time.Minute)
}
- prevToken := token.UnhashedToken
- refreshed, err := userAuthTokenService.RefreshToken(token, "1.1.1.1", "firefox")
+ prevToken := userToken.UnhashedToken
+ rotated, err := userAuthTokenService.TryRotateToken(userToken, "1.1.1.1", "firefox")
So(err, ShouldBeNil)
- So(refreshed, ShouldBeTrue)
+ So(rotated, ShouldBeTrue)
getTime = func() time.Time {
return t.Add(20 * time.Minute)
}
- current, err := userAuthTokenService.LookupToken(token.UnhashedToken)
+ currentUserToken, err := userAuthTokenService.LookupToken(userToken.UnhashedToken)
So(err, ShouldBeNil)
- So(current, ShouldNotBeNil)
+ So(currentUserToken, ShouldNotBeNil)
- prev, err := userAuthTokenService.LookupToken(prevToken)
+ prevUserToken, err := userAuthTokenService.LookupToken(prevToken)
So(err, ShouldBeNil)
- So(prev, ShouldNotBeNil)
+ So(prevUserToken, ShouldNotBeNil)
})
Convey("will not mark token unseen when prev and current are the same", func() {
- token, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent")
+ userToken, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent")
So(err, ShouldBeNil)
- So(token, ShouldNotBeNil)
+ So(userToken, ShouldNotBeNil)
- lookedUp, err := userAuthTokenService.LookupToken(token.UnhashedToken)
+ lookedUpUserToken, err := userAuthTokenService.LookupToken(userToken.UnhashedToken)
So(err, ShouldBeNil)
- So(lookedUp, ShouldNotBeNil)
+ So(lookedUpUserToken, ShouldNotBeNil)
- lookedUp, err = userAuthTokenService.LookupToken(token.UnhashedToken)
+ lookedUpUserToken, err = userAuthTokenService.LookupToken(userToken.UnhashedToken)
So(err, ShouldBeNil)
- So(lookedUp, ShouldNotBeNil)
+ So(lookedUpUserToken, ShouldNotBeNil)
- lookedUp, err = ctx.getAuthTokenByID(lookedUp.Id)
+ lookedUpModel, err := ctx.getAuthTokenByID(lookedUpUserToken.Id)
So(err, ShouldBeNil)
- So(lookedUp, ShouldNotBeNil)
- So(lookedUp.AuthTokenSeen, ShouldBeTrue)
+ So(lookedUpModel, ShouldNotBeNil)
+ So(lookedUpModel.AuthTokenSeen, ShouldBeTrue)
})
Convey("Rotate token", func() {
- token, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent")
+ userToken, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent")
So(err, ShouldBeNil)
- So(token, ShouldNotBeNil)
+ So(userToken, ShouldNotBeNil)
- prevToken := token.AuthToken
+ prevToken := userToken.AuthToken
Convey("Should rotate current token and previous token when auth token seen", func() {
- updated, err := ctx.markAuthTokenAsSeen(token.Id)
+ updated, err := ctx.markAuthTokenAsSeen(userToken.Id)
So(err, ShouldBeNil)
So(updated, ShouldBeTrue)
@@ -265,11 +292,11 @@ func TestUserAuthToken(t *testing.T) {
return t.Add(10 * time.Minute)
}
- refreshed, err := userAuthTokenService.RefreshToken(token, "1.1.1.1", "firefox")
+ rotated, err := userAuthTokenService.TryRotateToken(userToken, "1.1.1.1", "firefox")
So(err, ShouldBeNil)
- So(refreshed, ShouldBeTrue)
+ So(rotated, ShouldBeTrue)
- storedToken, err := ctx.getAuthTokenByID(token.Id)
+ storedToken, err := ctx.getAuthTokenByID(userToken.Id)
So(err, ShouldBeNil)
So(storedToken, ShouldNotBeNil)
So(storedToken.AuthTokenSeen, ShouldBeFalse)
@@ -278,7 +305,7 @@ func TestUserAuthToken(t *testing.T) {
prevToken = storedToken.AuthToken
- updated, err = ctx.markAuthTokenAsSeen(token.Id)
+ updated, err = ctx.markAuthTokenAsSeen(userToken.Id)
So(err, ShouldBeNil)
So(updated, ShouldBeTrue)
@@ -286,11 +313,11 @@ func TestUserAuthToken(t *testing.T) {
return t.Add(20 * time.Minute)
}
- refreshed, err = userAuthTokenService.RefreshToken(token, "1.1.1.1", "firefox")
+ rotated, err = userAuthTokenService.TryRotateToken(userToken, "1.1.1.1", "firefox")
So(err, ShouldBeNil)
- So(refreshed, ShouldBeTrue)
+ So(rotated, ShouldBeTrue)
- storedToken, err = ctx.getAuthTokenByID(token.Id)
+ storedToken, err = ctx.getAuthTokenByID(userToken.Id)
So(err, ShouldBeNil)
So(storedToken, ShouldNotBeNil)
So(storedToken.AuthTokenSeen, ShouldBeFalse)
@@ -299,17 +326,17 @@ func TestUserAuthToken(t *testing.T) {
})
Convey("Should rotate current token, but keep previous token when auth token not seen", func() {
- token.RotatedAt = getTime().Add(-2 * time.Minute).Unix()
+ userToken.RotatedAt = getTime().Add(-2 * time.Minute).Unix()
getTime = func() time.Time {
return t.Add(2 * time.Minute)
}
- refreshed, err := userAuthTokenService.RefreshToken(token, "1.1.1.1", "firefox")
+ rotated, err := userAuthTokenService.TryRotateToken(userToken, "1.1.1.1", "firefox")
So(err, ShouldBeNil)
- So(refreshed, ShouldBeTrue)
+ So(rotated, ShouldBeTrue)
- storedToken, err := ctx.getAuthTokenByID(token.Id)
+ storedToken, err := ctx.getAuthTokenByID(userToken.Id)
So(err, ShouldBeNil)
So(storedToken, ShouldNotBeNil)
So(storedToken.AuthTokenSeen, ShouldBeFalse)
@@ -318,6 +345,71 @@ func TestUserAuthToken(t *testing.T) {
})
})
+ Convey("When populating userAuthToken from UserToken should copy all properties", func() {
+ ut := models.UserToken{
+ Id: 1,
+ UserId: 2,
+ AuthToken: "a",
+ PrevAuthToken: "b",
+ UserAgent: "c",
+ ClientIp: "d",
+ AuthTokenSeen: true,
+ SeenAt: 3,
+ RotatedAt: 4,
+ CreatedAt: 5,
+ UpdatedAt: 6,
+ UnhashedToken: "e",
+ }
+ utBytes, err := json.Marshal(ut)
+ So(err, ShouldBeNil)
+ utJSON, err := simplejson.NewJson(utBytes)
+ So(err, ShouldBeNil)
+ utMap := utJSON.MustMap()
+
+ var uat userAuthToken
+ uat.fromUserToken(&ut)
+ uatBytes, err := json.Marshal(uat)
+ So(err, ShouldBeNil)
+ uatJSON, err := simplejson.NewJson(uatBytes)
+ So(err, ShouldBeNil)
+ uatMap := uatJSON.MustMap()
+
+ So(uatMap, ShouldResemble, utMap)
+ })
+
+ Convey("When populating userToken from userAuthToken should copy all properties", func() {
+ uat := userAuthToken{
+ Id: 1,
+ UserId: 2,
+ AuthToken: "a",
+ PrevAuthToken: "b",
+ UserAgent: "c",
+ ClientIp: "d",
+ AuthTokenSeen: true,
+ SeenAt: 3,
+ RotatedAt: 4,
+ CreatedAt: 5,
+ UpdatedAt: 6,
+ UnhashedToken: "e",
+ }
+ uatBytes, err := json.Marshal(uat)
+ So(err, ShouldBeNil)
+ uatJSON, err := simplejson.NewJson(uatBytes)
+ So(err, ShouldBeNil)
+ uatMap := uatJSON.MustMap()
+
+ var ut models.UserToken
+ err = uat.toUserToken(&ut)
+ So(err, ShouldBeNil)
+ utBytes, err := json.Marshal(ut)
+ So(err, ShouldBeNil)
+ utJSON, err := simplejson.NewJson(utBytes)
+ So(err, ShouldBeNil)
+ utMap := utJSON.MustMap()
+
+ So(utMap, ShouldResemble, uatMap)
+ })
+
Reset(func() {
getTime = time.Now
})
@@ -328,19 +420,16 @@ func createTestContext(t *testing.T) *testContext {
t.Helper()
sqlstore := sqlstore.InitTestDB(t)
- tokenService := &UserAuthTokenServiceImpl{
+ tokenService := &UserAuthTokenService{
SQLStore: sqlstore,
Cfg: &setting.Cfg{
- LoginCookieName: "grafana_session",
- LoginCookieMaxDays: 7,
- LoginDeleteExpiredTokensAfterDays: 30,
- LoginCookieRotation: 10,
+ LoginMaxInactiveLifetimeDays: 7,
+ LoginMaxLifetimeDays: 30,
+ TokenRotationIntervalMinutes: 10,
},
log: log.New("test-logger"),
}
- UrgentRotateTime = time.Minute
-
return &testContext{
sqlstore: sqlstore,
tokenService: tokenService,
@@ -349,7 +438,7 @@ func createTestContext(t *testing.T) *testContext {
type testContext struct {
sqlstore *sqlstore.SqlStore
- tokenService *UserAuthTokenServiceImpl
+ tokenService *UserAuthTokenService
}
func (c *testContext) getAuthTokenByID(id int64) (*userAuthToken, error) {
@@ -376,3 +465,17 @@ func (c *testContext) markAuthTokenAsSeen(id int64) (bool, error) {
}
return rowsAffected == 1, nil
}
+
+func (c *testContext) updateRotatedAt(id, rotatedAt int64) (bool, error) {
+ sess := c.sqlstore.NewSession()
+ res, err := sess.Exec("UPDATE user_auth_token SET rotated_at = ? WHERE id = ?", rotatedAt, id)
+ if err != nil {
+ return false, err
+ }
+
+ rowsAffected, err := res.RowsAffected()
+ if err != nil {
+ return false, err
+ }
+ return rowsAffected == 1, nil
+}
diff --git a/pkg/services/auth/model.go b/pkg/services/auth/model.go
index 7a0f49539f2..36652e70436 100644
--- a/pkg/services/auth/model.go
+++ b/pkg/services/auth/model.go
@@ -1,12 +1,9 @@
package auth
import (
- "errors"
-)
+ "fmt"
-// Typed errors
-var (
- ErrAuthTokenNotFound = errors.New("User auth token not found")
+ "github.com/grafana/grafana/pkg/models"
)
type userAuthToken struct {
@@ -23,3 +20,51 @@ type userAuthToken struct {
UpdatedAt int64
UnhashedToken string `xorm:"-"`
}
+
+func userAuthTokenFromUserToken(ut *models.UserToken) *userAuthToken {
+ var uat userAuthToken
+ uat.fromUserToken(ut)
+ return &uat
+}
+
+func (uat *userAuthToken) fromUserToken(ut *models.UserToken) error {
+ if uat == nil {
+ return fmt.Errorf("needs pointer to userAuthToken struct")
+ }
+
+ uat.Id = ut.Id
+ uat.UserId = ut.UserId
+ uat.AuthToken = ut.AuthToken
+ uat.PrevAuthToken = ut.PrevAuthToken
+ uat.UserAgent = ut.UserAgent
+ uat.ClientIp = ut.ClientIp
+ uat.AuthTokenSeen = ut.AuthTokenSeen
+ uat.SeenAt = ut.SeenAt
+ uat.RotatedAt = ut.RotatedAt
+ uat.CreatedAt = ut.CreatedAt
+ uat.UpdatedAt = ut.UpdatedAt
+ uat.UnhashedToken = ut.UnhashedToken
+
+ return nil
+}
+
+func (uat *userAuthToken) toUserToken(ut *models.UserToken) error {
+ if uat == nil {
+ return fmt.Errorf("needs pointer to userAuthToken struct")
+ }
+
+ ut.Id = uat.Id
+ ut.UserId = uat.UserId
+ ut.AuthToken = uat.AuthToken
+ ut.PrevAuthToken = uat.PrevAuthToken
+ ut.UserAgent = uat.UserAgent
+ ut.ClientIp = uat.ClientIp
+ ut.AuthTokenSeen = uat.AuthTokenSeen
+ ut.SeenAt = uat.SeenAt
+ ut.RotatedAt = uat.RotatedAt
+ ut.CreatedAt = uat.CreatedAt
+ ut.UpdatedAt = uat.UpdatedAt
+ ut.UnhashedToken = uat.UnhashedToken
+
+ return nil
+}
diff --git a/pkg/services/auth/session_cleanup.go b/pkg/services/auth/session_cleanup.go
deleted file mode 100644
index 7e523181a7b..00000000000
--- a/pkg/services/auth/session_cleanup.go
+++ /dev/null
@@ -1,38 +0,0 @@
-package auth
-
-import (
- "context"
- "time"
-)
-
-func (srv *UserAuthTokenServiceImpl) Run(ctx context.Context) error {
- ticker := time.NewTicker(time.Hour * 12)
- deleteSessionAfter := time.Hour * 24 * time.Duration(srv.Cfg.LoginDeleteExpiredTokensAfterDays)
-
- for {
- select {
- case <-ticker.C:
- srv.ServerLockService.LockAndExecute(ctx, "delete old sessions", time.Hour*12, func() {
- srv.deleteOldSession(deleteSessionAfter)
- })
-
- case <-ctx.Done():
- return ctx.Err()
- }
- }
-}
-
-func (srv *UserAuthTokenServiceImpl) deleteOldSession(deleteSessionAfter time.Duration) (int64, error) {
- sql := `DELETE from user_auth_token WHERE rotated_at < ?`
-
- deleteBefore := getTime().Add(-deleteSessionAfter)
- res, err := srv.SQLStore.NewSession().Exec(sql, deleteBefore.Unix())
- if err != nil {
- return 0, err
- }
-
- affected, err := res.RowsAffected()
- srv.log.Info("deleted old sessions", "count", affected)
-
- return affected, err
-}
diff --git a/pkg/services/auth/session_cleanup_test.go b/pkg/services/auth/session_cleanup_test.go
deleted file mode 100644
index eef2cd74d04..00000000000
--- a/pkg/services/auth/session_cleanup_test.go
+++ /dev/null
@@ -1,36 +0,0 @@
-package auth
-
-import (
- "fmt"
- "testing"
- "time"
-
- . "github.com/smartystreets/goconvey/convey"
-)
-
-func TestUserAuthTokenCleanup(t *testing.T) {
-
- Convey("Test user auth token cleanup", t, func() {
- ctx := createTestContext(t)
-
- insertToken := func(token string, prev string, rotatedAt int64) {
- ut := userAuthToken{AuthToken: token, PrevAuthToken: prev, RotatedAt: rotatedAt, UserAgent: "", ClientIp: ""}
- _, err := ctx.sqlstore.NewSession().Insert(&ut)
- So(err, ShouldBeNil)
- }
-
- // insert three old tokens that should be deleted
- for i := 0; i < 3; i++ {
- insertToken(fmt.Sprintf("oldA%d", i), fmt.Sprintf("oldB%d", i), int64(i))
- }
-
- // insert three active tokens that should not be deleted
- for i := 0; i < 3; i++ {
- insertToken(fmt.Sprintf("newA%d", i), fmt.Sprintf("newB%d", i), getTime().Unix())
- }
-
- affected, err := ctx.tokenService.deleteOldSession(time.Hour)
- So(err, ShouldBeNil)
- So(affected, ShouldEqual, 3)
- })
-}
diff --git a/pkg/services/auth/token_cleanup.go b/pkg/services/auth/token_cleanup.go
new file mode 100644
index 00000000000..aa5bc4856ab
--- /dev/null
+++ b/pkg/services/auth/token_cleanup.go
@@ -0,0 +1,57 @@
+package auth
+
+import (
+ "context"
+ "time"
+)
+
+func (srv *UserAuthTokenService) Run(ctx context.Context) error {
+ ticker := time.NewTicker(time.Hour)
+ maxInactiveLifetime := time.Duration(srv.Cfg.LoginMaxInactiveLifetimeDays) * 24 * time.Hour
+ maxLifetime := time.Duration(srv.Cfg.LoginMaxLifetimeDays) * 24 * time.Hour
+
+ err := srv.ServerLockService.LockAndExecute(ctx, "cleanup expired auth tokens", time.Hour*12, func() {
+ srv.deleteExpiredTokens(maxInactiveLifetime, maxLifetime)
+ })
+ if err != nil {
+ srv.log.Error("failed to lock and execite cleanup of expired auth token", "erro", err)
+ }
+
+ for {
+ select {
+ case <-ticker.C:
+ err := srv.ServerLockService.LockAndExecute(ctx, "cleanup expired auth tokens", time.Hour*12, func() {
+ srv.deleteExpiredTokens(maxInactiveLifetime, maxLifetime)
+ })
+
+ if err != nil {
+ srv.log.Error("failed to lock and execite cleanup of expired auth token", "erro", err)
+ }
+
+ case <-ctx.Done():
+ return ctx.Err()
+ }
+ }
+}
+
+func (srv *UserAuthTokenService) deleteExpiredTokens(maxInactiveLifetime, maxLifetime time.Duration) (int64, error) {
+ createdBefore := getTime().Add(-maxLifetime)
+ rotatedBefore := getTime().Add(-maxInactiveLifetime)
+
+ srv.log.Debug("starting cleanup of expired auth tokens", "createdBefore", createdBefore, "rotatedBefore", rotatedBefore)
+
+ sql := `DELETE from user_auth_token WHERE created_at <= ? OR rotated_at <= ?`
+ res, err := srv.SQLStore.NewSession().Exec(sql, createdBefore.Unix(), rotatedBefore.Unix())
+ if err != nil {
+ return 0, err
+ }
+
+ affected, err := res.RowsAffected()
+ if err != nil {
+ srv.log.Error("failed to cleanup expired auth tokens", "error", err)
+ return 0, nil
+ }
+
+ srv.log.Info("cleanup of expired auth tokens done", "count", affected)
+ return affected, err
+}
diff --git a/pkg/services/auth/token_cleanup_test.go b/pkg/services/auth/token_cleanup_test.go
new file mode 100644
index 00000000000..410764d3f8d
--- /dev/null
+++ b/pkg/services/auth/token_cleanup_test.go
@@ -0,0 +1,68 @@
+package auth
+
+import (
+ "fmt"
+ "testing"
+ "time"
+
+ . "github.com/smartystreets/goconvey/convey"
+)
+
+func TestUserAuthTokenCleanup(t *testing.T) {
+
+ Convey("Test user auth token cleanup", t, func() {
+ ctx := createTestContext(t)
+ ctx.tokenService.Cfg.LoginMaxInactiveLifetimeDays = 7
+ ctx.tokenService.Cfg.LoginMaxLifetimeDays = 30
+
+ insertToken := func(token string, prev string, createdAt, rotatedAt int64) {
+ ut := userAuthToken{AuthToken: token, PrevAuthToken: prev, CreatedAt: createdAt, RotatedAt: rotatedAt, UserAgent: "", ClientIp: ""}
+ _, err := ctx.sqlstore.NewSession().Insert(&ut)
+ So(err, ShouldBeNil)
+ }
+
+ t := time.Date(2018, 12, 13, 13, 45, 0, 0, time.UTC)
+ getTime = func() time.Time {
+ return t
+ }
+
+ Convey("should delete tokens where token rotation age is older than or equal 7 days", func() {
+ from := t.Add(-7 * 24 * time.Hour)
+
+ // insert three old tokens that should be deleted
+ for i := 0; i < 3; i++ {
+ insertToken(fmt.Sprintf("oldA%d", i), fmt.Sprintf("oldB%d", i), from.Unix(), from.Unix())
+ }
+
+ // insert three active tokens that should not be deleted
+ for i := 0; i < 3; i++ {
+ from = from.Add(time.Second)
+ insertToken(fmt.Sprintf("newA%d", i), fmt.Sprintf("newB%d", i), from.Unix(), from.Unix())
+ }
+
+ affected, err := ctx.tokenService.deleteExpiredTokens(7*24*time.Hour, 30*24*time.Hour)
+ So(err, ShouldBeNil)
+ So(affected, ShouldEqual, 3)
+ })
+
+ Convey("should delete tokens where token age is older than or equal 30 days", func() {
+ from := t.Add(-30 * 24 * time.Hour)
+ fromRotate := t.Add(-time.Second)
+
+ // insert three old tokens that should be deleted
+ for i := 0; i < 3; i++ {
+ insertToken(fmt.Sprintf("oldA%d", i), fmt.Sprintf("oldB%d", i), from.Unix(), fromRotate.Unix())
+ }
+
+ // insert three active tokens that should not be deleted
+ for i := 0; i < 3; i++ {
+ from = from.Add(time.Second)
+ insertToken(fmt.Sprintf("newA%d", i), fmt.Sprintf("newB%d", i), from.Unix(), fromRotate.Unix())
+ }
+
+ affected, err := ctx.tokenService.deleteExpiredTokens(7*24*time.Hour, 30*24*time.Hour)
+ So(err, ShouldBeNil)
+ So(affected, ShouldEqual, 3)
+ })
+ })
+}
diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go
index c3c78d10fec..21899482529 100644
--- a/pkg/setting/setting.go
+++ b/pkg/setting/setting.go
@@ -89,6 +89,8 @@ var (
EmailCodeValidMinutes int
DataProxyWhiteList map[string]bool
DisableBruteForceLoginProtection bool
+ CookieSecure bool
+ CookieSameSite http.SameSite
// Snapshots
ExternalSnapshotUrl string
@@ -118,8 +120,10 @@ var (
ViewersCanEdit bool
// Http auth
- AdminUser string
- AdminPassword string
+ AdminUser string
+ AdminPassword string
+ LoginCookieName string
+ LoginMaxLifetimeDays int
AnonymousEnabled bool
AnonymousOrgName string
@@ -215,7 +219,11 @@ type Cfg struct {
RendererLimit int
RendererLimitAlerting int
+ // Security
DisableBruteForceLoginProtection bool
+ CookieSecure bool
+ CookieSameSite http.SameSite
+
TempDataLifetime time.Duration
MetricsEndpointEnabled bool
MetricsEndpointBasicAuthUsername string
@@ -224,13 +232,11 @@ type Cfg struct {
DisableSanitizeHtml bool
EnterpriseLicensePath string
- LoginCookieName string
- LoginCookieMaxDays int
- LoginCookieRotation int
- LoginDeleteExpiredTokensAfterDays int
- LoginCookieSameSite http.SameSite
-
- SecurityHTTPSCookies bool
+ // Auth
+ LoginCookieName string
+ LoginMaxInactiveLifetimeDays int
+ LoginMaxLifetimeDays int
+ TokenRotationIntervalMinutes int
}
type CommandLineArgs struct {
@@ -554,30 +560,6 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
ApplicationName = APP_NAME_ENTERPRISE
}
- //login
- login := iniFile.Section("login")
- cfg.LoginCookieName = login.Key("cookie_name").MustString("grafana_session")
- cfg.LoginCookieMaxDays = login.Key("login_remember_days").MustInt(7)
- cfg.LoginDeleteExpiredTokensAfterDays = login.Key("delete_expired_token_after_days").MustInt(30)
-
- samesiteString := login.Key("cookie_samesite").MustString("lax")
- validSameSiteValues := map[string]http.SameSite{
- "lax": http.SameSiteLaxMode,
- "strict": http.SameSiteStrictMode,
- "none": http.SameSiteDefaultMode,
- }
-
- if samesite, ok := validSameSiteValues[samesiteString]; ok {
- cfg.LoginCookieSameSite = samesite
- } else {
- cfg.LoginCookieSameSite = http.SameSiteLaxMode
- }
-
- cfg.LoginCookieRotation = login.Key("rotate_token_minutes").MustInt(10)
- if cfg.LoginCookieRotation < 2 {
- cfg.LoginCookieRotation = 2
- }
-
Env = iniFile.Section("").Key("app_mode").MustString("development")
InstanceName = iniFile.Section("").Key("instance_name").MustString("unknown_instance_name")
PluginsPath = makeAbsolute(iniFile.Section("paths").Key("plugins").String(), HomePath)
@@ -621,9 +603,26 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
SecretKey = security.Key("secret_key").String()
DisableGravatar = security.Key("disable_gravatar").MustBool(true)
cfg.DisableBruteForceLoginProtection = security.Key("disable_brute_force_login_protection").MustBool(false)
- cfg.SecurityHTTPSCookies = security.Key("https_flag_cookies").MustBool(false)
DisableBruteForceLoginProtection = cfg.DisableBruteForceLoginProtection
+ CookieSecure = security.Key("cookie_secure").MustBool(false)
+ cfg.CookieSecure = CookieSecure
+
+ samesiteString := security.Key("cookie_samesite").MustString("lax")
+ validSameSiteValues := map[string]http.SameSite{
+ "lax": http.SameSiteLaxMode,
+ "strict": http.SameSiteStrictMode,
+ "none": http.SameSiteDefaultMode,
+ }
+
+ if samesite, ok := validSameSiteValues[samesiteString]; ok {
+ CookieSameSite = samesite
+ cfg.CookieSameSite = CookieSameSite
+ } else {
+ CookieSameSite = http.SameSiteLaxMode
+ cfg.CookieSameSite = CookieSameSite
+ }
+
// read snapshots settings
snapshots := iniFile.Section("snapshots")
ExternalSnapshotUrl = snapshots.Key("external_snapshot_url").String()
@@ -661,6 +660,19 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
// auth
auth := iniFile.Section("auth")
+
+ LoginCookieName = auth.Key("login_cookie_name").MustString("grafana_session")
+ cfg.LoginCookieName = LoginCookieName
+ cfg.LoginMaxInactiveLifetimeDays = auth.Key("login_maximum_inactive_lifetime_days").MustInt(7)
+
+ LoginMaxLifetimeDays = auth.Key("login_maximum_lifetime_days").MustInt(30)
+ cfg.LoginMaxLifetimeDays = LoginMaxLifetimeDays
+
+ cfg.TokenRotationIntervalMinutes = auth.Key("token_rotation_interval_minutes").MustInt(10)
+ if cfg.TokenRotationIntervalMinutes < 2 {
+ cfg.TokenRotationIntervalMinutes = 2
+ }
+
DisableLoginForm = auth.Key("disable_login_form").MustBool(false)
DisableSignoutMenu = auth.Key("disable_signout_menu").MustBool(false)
OAuthAutoLogin = auth.Key("oauth_auto_login").MustBool(false)
diff --git a/pkg/tsdb/cloudwatch/cloudwatch.go b/pkg/tsdb/cloudwatch/cloudwatch.go
index 8bb1ab6c928..8d67fe7db8c 100644
--- a/pkg/tsdb/cloudwatch/cloudwatch.go
+++ b/pkg/tsdb/cloudwatch/cloudwatch.go
@@ -21,6 +21,7 @@ import (
"github.com/aws/aws-sdk-go/aws/request"
"github.com/aws/aws-sdk-go/service/cloudwatch"
"github.com/aws/aws-sdk-go/service/ec2/ec2iface"
+ "github.com/aws/aws-sdk-go/service/resourcegroupstaggingapi/resourcegroupstaggingapiiface"
"github.com/grafana/grafana/pkg/components/null"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/metrics"
@@ -28,7 +29,8 @@ import (
type CloudWatchExecutor struct {
*models.DataSource
- ec2Svc ec2iface.EC2API
+ ec2Svc ec2iface.EC2API
+ rgtaSvc resourcegroupstaggingapiiface.ResourceGroupsTaggingAPIAPI
}
type DatasourceInfo struct {
diff --git a/pkg/tsdb/cloudwatch/metric_find_query.go b/pkg/tsdb/cloudwatch/metric_find_query.go
index f898a65f911..ddda26dfd24 100644
--- a/pkg/tsdb/cloudwatch/metric_find_query.go
+++ b/pkg/tsdb/cloudwatch/metric_find_query.go
@@ -15,6 +15,7 @@ import (
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/cloudwatch"
"github.com/aws/aws-sdk-go/service/ec2"
+ "github.com/aws/aws-sdk-go/service/resourcegroupstaggingapi"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/metrics"
"github.com/grafana/grafana/pkg/tsdb"
@@ -54,6 +55,7 @@ func init() {
"AWS/DynamoDB": {"ConditionalCheckFailedRequests", "ConsumedReadCapacityUnits", "ConsumedWriteCapacityUnits", "OnlineIndexConsumedWriteCapacity", "OnlineIndexPercentageProgress", "OnlineIndexThrottleEvents", "ProvisionedReadCapacityUnits", "ProvisionedWriteCapacityUnits", "ReadThrottleEvents", "ReturnedBytes", "ReturnedItemCount", "ReturnedRecordsCount", "SuccessfulRequestLatency", "SystemErrors", "TimeToLiveDeletedItemCount", "ThrottledRequests", "UserErrors", "WriteThrottleEvents"},
"AWS/EBS": {"VolumeReadBytes", "VolumeWriteBytes", "VolumeReadOps", "VolumeWriteOps", "VolumeTotalReadTime", "VolumeTotalWriteTime", "VolumeIdleTime", "VolumeQueueLength", "VolumeThroughputPercentage", "VolumeConsumedReadWriteOps", "BurstBalance"},
"AWS/EC2": {"CPUCreditUsage", "CPUCreditBalance", "CPUUtilization", "DiskReadOps", "DiskWriteOps", "DiskReadBytes", "DiskWriteBytes", "NetworkIn", "NetworkOut", "NetworkPacketsIn", "NetworkPacketsOut", "StatusCheckFailed", "StatusCheckFailed_Instance", "StatusCheckFailed_System"},
+ "AWS/EC2/API": {"ClientErrors", "RequestLimitExceeded", "ServerErrors", "SuccessfulCalls"},
"AWS/EC2Spot": {"AvailableInstancePoolsCount", "BidsSubmittedForCapacity", "EligibleInstancePoolCount", "FulfilledCapacity", "MaxPercentCapacityAllocation", "PendingCapacity", "PercentCapacityAllocation", "TargetCapacity", "TerminatingCapacity"},
"AWS/ECS": {"CPUReservation", "MemoryReservation", "CPUUtilization", "MemoryUtilization"},
"AWS/EFS": {"BurstCreditBalance", "ClientConnections", "DataReadIOBytes", "DataWriteIOBytes", "MetadataIOBytes", "TotalIOBytes", "PermittedThroughput", "PercentIOLimit"},
@@ -99,7 +101,7 @@ func init() {
"AWS/NetworkELB": {"ActiveFlowCount", "ConsumedLCUs", "HealthyHostCount", "NewFlowCount", "ProcessedBytes", "TCP_Client_Reset_Count", "TCP_ELB_Reset_Count", "TCP_Target_Reset_Count", "UnHealthyHostCount"},
"AWS/OpsWorks": {"cpu_idle", "cpu_nice", "cpu_system", "cpu_user", "cpu_waitio", "load_1", "load_5", "load_15", "memory_buffers", "memory_cached", "memory_free", "memory_swap", "memory_total", "memory_used", "procs"},
"AWS/Redshift": {"CPUUtilization", "DatabaseConnections", "HealthStatus", "MaintenanceMode", "NetworkReceiveThroughput", "NetworkTransmitThroughput", "PercentageDiskSpaceUsed", "QueriesCompletedPerSecond", "QueryDuration", "QueryRuntimeBreakdown", "ReadIOPS", "ReadLatency", "ReadThroughput", "WLMQueriesCompletedPerSecond", "WLMQueryDuration", "WLMQueueLength", "WriteIOPS", "WriteLatency", "WriteThroughput"},
- "AWS/RDS": {"ActiveTransactions", "AuroraBinlogReplicaLag", "AuroraReplicaLag", "AuroraReplicaLagMaximum", "AuroraReplicaLagMinimum", "BinLogDiskUsage", "BlockedTransactions", "BufferCacheHitRatio", "BurstBalance", "CommitLatency", "CommitThroughput", "BinLogDiskUsage", "CPUCreditBalance", "CPUCreditUsage", "CPUUtilization", "DatabaseConnections", "DDLLatency", "DDLThroughput", "Deadlocks", "DeleteLatency", "DeleteThroughput", "DiskQueueDepth", "DMLLatency", "DMLThroughput", "EngineUptime", "FailedSqlStatements", "FreeableMemory", "FreeLocalStorage", "FreeStorageSpace", "InsertLatency", "InsertThroughput", "LoginFailures", "NetworkReceiveThroughput", "NetworkTransmitThroughput", "NetworkThroughput", "Queries", "ReadIOPS", "ReadLatency", "ReadThroughput", "ReplicaLag", "ResultSetCacheHitRatio", "SelectLatency", "SelectThroughput", "SwapUsage", "TotalConnections", "UpdateLatency", "UpdateThroughput", "VolumeBytesUsed", "VolumeReadIOPS", "VolumeWriteIOPS", "WriteIOPS", "WriteLatency", "WriteThroughput"},
+ "AWS/RDS": {"ActiveTransactions", "AuroraBinlogReplicaLag", "AuroraReplicaLag", "AuroraReplicaLagMaximum", "AuroraReplicaLagMinimum", "BinLogDiskUsage", "BlockedTransactions", "BufferCacheHitRatio", "BurstBalance", "CommitLatency", "CommitThroughput", "BinLogDiskUsage", "CPUCreditBalance", "CPUCreditUsage", "CPUUtilization", "DatabaseConnections", "DDLLatency", "DDLThroughput", "Deadlocks", "DeleteLatency", "DeleteThroughput", "DiskQueueDepth", "DMLLatency", "DMLThroughput", "EngineUptime", "FailedSqlStatements", "FreeableMemory", "FreeLocalStorage", "FreeStorageSpace", "InsertLatency", "InsertThroughput", "LoginFailures", "NetworkReceiveThroughput", "NetworkTransmitThroughput", "NetworkThroughput", "Queries", "ReadIOPS", "ReadLatency", "ReadThroughput", "ReplicaLag", "ResultSetCacheHitRatio", "SelectLatency", "SelectThroughput", "ServerlessDatabaseCapacity", "SwapUsage", "TotalConnections", "UpdateLatency", "UpdateThroughput", "VolumeBytesUsed", "VolumeReadIOPS", "VolumeWriteIOPS", "WriteIOPS", "WriteLatency", "WriteThroughput"},
"AWS/Route53": {"ChildHealthCheckHealthyCount", "HealthCheckStatus", "HealthCheckPercentageHealthy", "ConnectionTime", "SSLHandshakeTime", "TimeToFirstByte"},
"AWS/S3": {"BucketSizeBytes", "NumberOfObjects", "AllRequests", "GetRequests", "PutRequests", "DeleteRequests", "HeadRequests", "PostRequests", "ListRequests", "BytesDownloaded", "BytesUploaded", "4xxErrors", "5xxErrors", "FirstByteLatency", "TotalRequestLatency"},
"AWS/SES": {"Bounce", "Complaint", "Delivery", "Reject", "Send", "Reputation.BounceRate", "Reputation.ComplaintRate"},
@@ -132,6 +134,7 @@ func init() {
"AWS/DynamoDB": {"TableName", "GlobalSecondaryIndexName", "Operation", "StreamLabel"},
"AWS/EBS": {"VolumeId"},
"AWS/EC2": {"AutoScalingGroupName", "ImageId", "InstanceId", "InstanceType"},
+ "AWS/EC2/API": {},
"AWS/EC2Spot": {"AvailabilityZone", "FleetRequestId", "InstanceType"},
"AWS/ECS": {"ClusterName", "ServiceName"},
"AWS/EFS": {"FileSystemId"},
@@ -200,6 +203,8 @@ func (e *CloudWatchExecutor) executeMetricFindQuery(ctx context.Context, queryCo
data, err = e.handleGetEbsVolumeIds(ctx, parameters, queryContext)
case "ec2_instance_attribute":
data, err = e.handleGetEc2InstanceAttribute(ctx, parameters, queryContext)
+ case "resource_arns":
+ data, err = e.handleGetResourceArns(ctx, parameters, queryContext)
}
transformToTable(data, queryResult)
@@ -536,6 +541,65 @@ func (e *CloudWatchExecutor) handleGetEc2InstanceAttribute(ctx context.Context,
return result, nil
}
+func (e *CloudWatchExecutor) ensureRGTAClientSession(region string) error {
+ if e.rgtaSvc == nil {
+ dsInfo := e.getDsInfo(region)
+ cfg, err := e.getAwsConfig(dsInfo)
+ if err != nil {
+ return fmt.Errorf("Failed to call ec2:getAwsConfig, %v", err)
+ }
+ sess, err := session.NewSession(cfg)
+ if err != nil {
+ return fmt.Errorf("Failed to call ec2:NewSession, %v", err)
+ }
+ e.rgtaSvc = resourcegroupstaggingapi.New(sess, cfg)
+ }
+ return nil
+}
+
+func (e *CloudWatchExecutor) handleGetResourceArns(ctx context.Context, parameters *simplejson.Json, queryContext *tsdb.TsdbQuery) ([]suggestData, error) {
+ region := parameters.Get("region").MustString()
+ resourceType := parameters.Get("resourceType").MustString()
+ filterJson := parameters.Get("tags").MustMap()
+
+ err := e.ensureRGTAClientSession(region)
+ if err != nil {
+ return nil, err
+ }
+
+ var filters []*resourcegroupstaggingapi.TagFilter
+ for k, v := range filterJson {
+ if vv, ok := v.([]interface{}); ok {
+ var vvvvv []*string
+ for _, vvv := range vv {
+ if vvvv, ok := vvv.(string); ok {
+ vvvvv = append(vvvvv, &vvvv)
+ }
+ }
+ filters = append(filters, &resourcegroupstaggingapi.TagFilter{
+ Key: aws.String(k),
+ Values: vvvvv,
+ })
+ }
+ }
+
+ var resourceTypes []*string
+ resourceTypes = append(resourceTypes, &resourceType)
+
+ resources, err := e.resourceGroupsGetResources(region, filters, resourceTypes)
+ if err != nil {
+ return nil, err
+ }
+
+ result := make([]suggestData, 0)
+ for _, resource := range resources.ResourceTagMappingList {
+ data := *resource.ResourceARN
+ result = append(result, suggestData{Text: data, Value: data})
+ }
+
+ return result, nil
+}
+
func (e *CloudWatchExecutor) cloudwatchListMetrics(region string, namespace string, metricName string, dimensions []*cloudwatch.DimensionFilter) (*cloudwatch.ListMetricsOutput, error) {
svc, err := e.getClient(region)
if err != nil {
@@ -587,6 +651,28 @@ func (e *CloudWatchExecutor) ec2DescribeInstances(region string, filters []*ec2.
return &resp, nil
}
+func (e *CloudWatchExecutor) resourceGroupsGetResources(region string, filters []*resourcegroupstaggingapi.TagFilter, resourceTypes []*string) (*resourcegroupstaggingapi.GetResourcesOutput, error) {
+ params := &resourcegroupstaggingapi.GetResourcesInput{
+ ResourceTypeFilters: resourceTypes,
+ TagFilters: filters,
+ }
+
+ var resp resourcegroupstaggingapi.GetResourcesOutput
+ err := e.rgtaSvc.GetResourcesPages(params,
+ func(page *resourcegroupstaggingapi.GetResourcesOutput, lastPage bool) bool {
+ resources, _ := awsutil.ValuesAtPath(page, "ResourceTagMappingList")
+ for _, resource := range resources {
+ resp.ResourceTagMappingList = append(resp.ResourceTagMappingList, resource.(*resourcegroupstaggingapi.ResourceTagMapping))
+ }
+ return !lastPage
+ })
+ if err != nil {
+ return nil, errors.New("Failed to call tags:GetResources")
+ }
+
+ return &resp, nil
+}
+
func getAllMetrics(cwData *DatasourceInfo) (cloudwatch.ListMetricsOutput, error) {
creds, err := GetCredentials(cwData)
if err != nil {
diff --git a/pkg/tsdb/cloudwatch/metric_find_query_test.go b/pkg/tsdb/cloudwatch/metric_find_query_test.go
index 34c3379b4df..bc6c8b163a0 100644
--- a/pkg/tsdb/cloudwatch/metric_find_query_test.go
+++ b/pkg/tsdb/cloudwatch/metric_find_query_test.go
@@ -8,6 +8,8 @@ import (
"github.com/aws/aws-sdk-go/service/cloudwatch"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/aws/aws-sdk-go/service/ec2/ec2iface"
+ "github.com/aws/aws-sdk-go/service/resourcegroupstaggingapi"
+ "github.com/aws/aws-sdk-go/service/resourcegroupstaggingapi/resourcegroupstaggingapiiface"
"github.com/bmizerany/assert"
"github.com/grafana/grafana/pkg/components/securejsondata"
"github.com/grafana/grafana/pkg/components/simplejson"
@@ -22,6 +24,11 @@ type mockedEc2 struct {
RespRegions ec2.DescribeRegionsOutput
}
+type mockedRGTA struct {
+ resourcegroupstaggingapiiface.ResourceGroupsTaggingAPIAPI
+ Resp resourcegroupstaggingapi.GetResourcesOutput
+}
+
func (m mockedEc2) DescribeInstancesPages(in *ec2.DescribeInstancesInput, fn func(*ec2.DescribeInstancesOutput, bool) bool) error {
fn(&m.Resp, true)
return nil
@@ -30,6 +37,11 @@ func (m mockedEc2) DescribeRegions(in *ec2.DescribeRegionsInput) (*ec2.DescribeR
return &m.RespRegions, nil
}
+func (m mockedRGTA) GetResourcesPages(in *resourcegroupstaggingapi.GetResourcesInput, fn func(*resourcegroupstaggingapi.GetResourcesOutput, bool) bool) error {
+ fn(&m.Resp, true)
+ return nil
+}
+
func TestCloudWatchMetrics(t *testing.T) {
Convey("When calling getMetricsForCustomMetrics", t, func() {
@@ -209,6 +221,51 @@ func TestCloudWatchMetrics(t *testing.T) {
So(result[7].Text, ShouldEqual, "vol-4-2")
})
})
+
+ Convey("When calling handleGetResourceArns", t, func() {
+ executor := &CloudWatchExecutor{
+ rgtaSvc: mockedRGTA{
+ Resp: resourcegroupstaggingapi.GetResourcesOutput{
+ ResourceTagMappingList: []*resourcegroupstaggingapi.ResourceTagMapping{
+ {
+ ResourceARN: aws.String("arn:aws:ec2:us-east-1:123456789012:instance/i-12345678901234567"),
+ Tags: []*resourcegroupstaggingapi.Tag{
+ {
+ Key: aws.String("Environment"),
+ Value: aws.String("production"),
+ },
+ },
+ },
+ {
+ ResourceARN: aws.String("arn:aws:ec2:us-east-1:123456789012:instance/i-76543210987654321"),
+ Tags: []*resourcegroupstaggingapi.Tag{
+ {
+ Key: aws.String("Environment"),
+ Value: aws.String("production"),
+ },
+ },
+ },
+ },
+ },
+ },
+ }
+
+ json := simplejson.New()
+ json.Set("region", "us-east-1")
+ json.Set("resourceType", "ec2:instance")
+ tags := make(map[string]interface{})
+ tags["Environment"] = []string{"production"}
+ json.Set("tags", tags)
+ result, _ := executor.handleGetResourceArns(context.Background(), json, &tsdb.TsdbQuery{})
+
+ Convey("Should return all two instances", func() {
+ So(result[0].Text, ShouldEqual, "arn:aws:ec2:us-east-1:123456789012:instance/i-12345678901234567")
+ So(result[0].Value, ShouldEqual, "arn:aws:ec2:us-east-1:123456789012:instance/i-12345678901234567")
+ So(result[1].Text, ShouldEqual, "arn:aws:ec2:us-east-1:123456789012:instance/i-76543210987654321")
+ So(result[1].Value, ShouldEqual, "arn:aws:ec2:us-east-1:123456789012:instance/i-76543210987654321")
+
+ })
+ })
}
func TestParseMultiSelectValue(t *testing.T) {
diff --git a/pkg/tsdb/mysql/mysql.go b/pkg/tsdb/mysql/mysql.go
index 35b03e489a0..d307e12166c 100644
--- a/pkg/tsdb/mysql/mysql.go
+++ b/pkg/tsdb/mysql/mysql.go
@@ -32,6 +32,18 @@ func newMysqlQueryEndpoint(datasource *models.DataSource) (tsdb.TsdbQueryEndpoin
datasource.Url,
datasource.Database,
)
+
+ tlsConfig, err := datasource.GetTLSConfig()
+ if err != nil {
+ return nil, err
+ }
+
+ if tlsConfig.RootCAs != nil || len(tlsConfig.Certificates) > 0 {
+ tlsConfigString := fmt.Sprintf("ds%d", datasource.Id)
+ mysql.RegisterTLSConfig(tlsConfigString, tlsConfig)
+ cnnstr += "&tls=" + tlsConfigString
+ }
+
logger.Debug("getEngine", "connection", cnnstr)
config := tsdb.SqlQueryEndpointConfiguration{
diff --git a/public/app/core/angular_wrappers.ts b/public/app/core/angular_wrappers.ts
index 4806275e87d..6db442e7470 100644
--- a/public/app/core/angular_wrappers.ts
+++ b/public/app/core/angular_wrappers.ts
@@ -9,7 +9,7 @@ import { TagFilter } from './components/TagFilter/TagFilter';
import { SideMenu } from './components/sidemenu/SideMenu';
import { MetricSelect } from './components/Select/MetricSelect';
import AppNotificationList from './components/AppNotifications/AppNotificationList';
-import { ColorPicker, SeriesColorPickerPopover } from '@grafana/ui';
+import { ColorPicker, SeriesColorPickerPopoverWithTheme } from '@grafana/ui';
export function registerAngularDirectives() {
react2AngularDirective('passwordStrength', PasswordStrength, ['password']);
@@ -27,7 +27,7 @@ export function registerAngularDirectives() {
'color',
['onChange', { watchDepth: 'reference', wrapApply: true }],
]);
- react2AngularDirective('seriesColorPickerPopover', SeriesColorPickerPopover, [
+ react2AngularDirective('seriesColorPickerPopover', SeriesColorPickerPopoverWithTheme, [
'color',
'series',
'onColorChange',
diff --git a/public/app/core/app_events.ts b/public/app/core/app_events.ts
index 6af7913167b..1951fd87001 100644
--- a/public/app/core/app_events.ts
+++ b/public/app/core/app_events.ts
@@ -1,4 +1,5 @@
import { Emitter } from './utils/emitter';
-const appEvents = new Emitter();
+export const appEvents = new Emitter();
+
export default appEvents;
diff --git a/public/app/core/components/AlertBox/AlertBox.tsx b/public/app/core/components/AlertBox/AlertBox.tsx
new file mode 100644
index 00000000000..c99bf11ed7c
--- /dev/null
+++ b/public/app/core/components/AlertBox/AlertBox.tsx
@@ -0,0 +1,42 @@
+import React, { FunctionComponent } from 'react';
+import { AppNotificationSeverity } from 'app/types';
+
+interface Props {
+ title: string;
+ icon?: string;
+ text?: string;
+ severity: AppNotificationSeverity;
+ onClose?: () => void;
+}
+
+function getIconFromSeverity(severity: AppNotificationSeverity): string {
+ switch (severity) {
+ case AppNotificationSeverity.Error: {
+ return 'fa fa-exclamation-triangle';
+ }
+ case AppNotificationSeverity.Success: {
+ return 'fa fa-check';
+ }
+ default:
+ return null;
+ }
+}
+
+export const AlertBox: FunctionComponent = ({ title, icon, text, severity, onClose }) => {
+ return (
+
+
+
+
+
+
{title}
+ {text &&
{text}
}
+
+ {onClose && (
+
+
+
+ )}
+
+ );
+};
diff --git a/public/app/core/components/AppNotifications/AppNotificationItem.tsx b/public/app/core/components/AppNotifications/AppNotificationItem.tsx
index 6b4b268eb13..d1fc506d54c 100644
--- a/public/app/core/components/AppNotifications/AppNotificationItem.tsx
+++ b/public/app/core/components/AppNotifications/AppNotificationItem.tsx
@@ -1,5 +1,6 @@
import React, { Component } from 'react';
import { AppNotification } from 'app/types';
+import { AlertBox } from '../AlertBox/AlertBox';
interface Props {
appNotification: AppNotification;
@@ -22,18 +23,13 @@ export default class AppNotificationItem extends Component {
const { appNotification, onClearNotification } = this.props;
return (
-
-
-
-
-
-
{appNotification.title}
-
{appNotification.text}
-
-
onClearNotification(appNotification.id)}>
-
-
-
+ onClearNotification(appNotification.id)}
+ />
);
}
}
diff --git a/public/app/core/components/Page/Page.tsx b/public/app/core/components/Page/Page.tsx
index c4846ecf85d..997f02b700c 100644
--- a/public/app/core/components/Page/Page.tsx
+++ b/public/app/core/components/Page/Page.tsx
@@ -17,13 +17,10 @@ interface Props {
}
class Page extends Component {
- private bodyClass = 'is-react';
- private body = document.body;
static Header = PageHeader;
static Contents = PageContents;
componentDidMount() {
- this.body.classList.add(this.bodyClass);
this.updateTitle();
}
@@ -33,10 +30,6 @@ class Page extends Component {
}
}
- componentWillUnmount() {
- this.body.classList.remove(this.bodyClass);
- }
-
updateTitle = () => {
const title = this.getPageTitle;
document.title = title ? title + ' - Grafana' : 'Grafana';
diff --git a/public/app/core/components/gf_page.ts b/public/app/core/components/gf_page.ts
deleted file mode 100644
index 057a307f205..00000000000
--- a/public/app/core/components/gf_page.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import coreModule from 'app/core/core_module';
-
-const template = `
-
-`;
-
-export function gfPageDirective() {
- return {
- restrict: 'E',
- template: template,
- scope: {
- model: '=',
- },
- transclude: {
- header: '?gfPageHeader',
- body: 'gfPageBody',
- },
- link: (scope, elem, attrs) => {
- console.log(scope);
- },
- };
-}
-
-coreModule.directive('gfPage', gfPageDirective);
diff --git a/public/app/core/components/scroll/page_scroll.ts b/public/app/core/components/scroll/page_scroll.ts
deleted file mode 100644
index 2d6e27f8b22..00000000000
--- a/public/app/core/components/scroll/page_scroll.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-import coreModule from 'app/core/core_module';
-import appEvents from 'app/core/app_events';
-
-export function pageScrollbar() {
- return {
- restrict: 'A',
- link: (scope, elem, attrs) => {
- let lastPos = 0;
-
- appEvents.on(
- 'dash-scroll',
- evt => {
- if (evt.restore) {
- elem[0].scrollTop = lastPos;
- return;
- }
-
- lastPos = elem[0].scrollTop;
-
- if (evt.animate) {
- elem.animate({ scrollTop: evt.pos }, 500);
- } else {
- elem[0].scrollTop = evt.pos;
- }
- },
- scope
- );
-
- scope.$on('$routeChangeSuccess', () => {
- lastPos = 0;
- elem[0].scrollTop = 0;
- // Focus page to enable scrolling by keyboard
- elem[0].focus({ preventScroll: true });
- });
-
- elem[0].tabIndex = -1;
- // Focus page to enable scrolling by keyboard
- elem[0].focus({ preventScroll: true });
- },
- };
-}
-
-coreModule.directive('pageScrollbar', pageScrollbar);
diff --git a/public/app/core/config.ts b/public/app/core/config.ts
index 395e40e914b..f4254ac251a 100644
--- a/public/app/core/config.ts
+++ b/public/app/core/config.ts
@@ -1,5 +1,6 @@
import _ from 'lodash';
import { PanelPlugin } from 'app/types/plugins';
+import { GrafanaTheme, getTheme, GrafanaThemeType } from '@grafana/ui';
export interface BuildInfo {
version: string;
@@ -36,8 +37,11 @@ export class Settings {
loginError: any;
viewersCanEdit: boolean;
disableSanitizeHtml: boolean;
+ theme: GrafanaTheme;
constructor(options: Settings) {
+ this.theme = options.bootData.user.lightTheme ? getTheme(GrafanaThemeType.Light) : getTheme(GrafanaThemeType.Dark);
+
const defaults = {
datasources: {},
windowTitlePrefix: 'Grafana - ',
@@ -68,5 +72,5 @@ const bootData = (window as any).grafanaBootData || {
const options = bootData.settings;
options.bootData = bootData;
-const config = new Settings(options);
+export const config = new Settings(options);
export default config;
diff --git a/public/app/core/copy/appNotification.ts b/public/app/core/copy/appNotification.ts
index c34480d7aad..2869c121fa8 100644
--- a/public/app/core/copy/appNotification.ts
+++ b/public/app/core/copy/appNotification.ts
@@ -1,4 +1,5 @@
import { AppNotification, AppNotificationSeverity, AppNotificationTimeout } from 'app/types';
+import { getMessageFromError } from 'app/core/utils/errors';
const defaultSuccessNotification: AppNotification = {
title: '',
@@ -31,12 +32,14 @@ export const createSuccessNotification = (title: string, text?: string): AppNoti
id: Date.now(),
});
-export const createErrorNotification = (title: string, text?: string): AppNotification => ({
- ...defaultErrorNotification,
- title: title,
- text: text,
- id: Date.now(),
-});
+export const createErrorNotification = (title: string, text?: any): AppNotification => {
+ return {
+ ...defaultErrorNotification,
+ title: title,
+ text: getMessageFromError(text),
+ id: Date.now(),
+ };
+};
export const createWarningNotification = (title: string, text?: string): AppNotification => ({
...defaultWarningNotification,
diff --git a/public/app/core/core.ts b/public/app/core/core.ts
index fb38cefd435..1f289fc4b27 100644
--- a/public/app/core/core.ts
+++ b/public/app/core/core.ts
@@ -43,8 +43,6 @@ import { helpModal } from './components/help/help';
import { JsonExplorer } from './components/json_explorer/json_explorer';
import { NavModelSrv, NavModel } from './nav_model_srv';
import { geminiScrollbar } from './components/scroll/scroll';
-import { pageScrollbar } from './components/scroll/page_scroll';
-import { gfPageDirective } from './components/gf_page';
import { orgSwitcher } from './components/org_switcher';
import { profiler } from './profiler';
import { registerAngularDirectives } from './angular_wrappers';
@@ -79,8 +77,6 @@ export {
NavModelSrv,
NavModel,
geminiScrollbar,
- pageScrollbar,
- gfPageDirective,
orgSwitcher,
manageDashboardsDirective,
TimeSeries,
diff --git a/public/app/core/reducers/location.ts b/public/app/core/reducers/location.ts
index 6b39710dcca..c038ab53c9f 100644
--- a/public/app/core/reducers/location.ts
+++ b/public/app/core/reducers/location.ts
@@ -8,12 +8,13 @@ export const initialState: LocationState = {
path: '',
query: {},
routeParams: {},
+ replace: false,
};
export const locationReducer = (state = initialState, action: Action): LocationState => {
switch (action.type) {
case CoreActionTypes.UpdateLocation: {
- const { path, routeParams } = action.payload;
+ const { path, routeParams, replace } = action.payload;
let query = action.payload.query || state.query;
if (action.payload.partial) {
@@ -26,6 +27,7 @@ export const locationReducer = (state = initialState, action: Action): LocationS
path: path || state.path,
query: { ...query },
routeParams: routeParams || state.routeParams,
+ replace: replace === true,
};
}
}
diff --git a/public/app/core/redux/actionCreatorFactory.ts b/public/app/core/redux/actionCreatorFactory.ts
index d6477144df4..df0f02f5c99 100644
--- a/public/app/core/redux/actionCreatorFactory.ts
+++ b/public/app/core/redux/actionCreatorFactory.ts
@@ -53,5 +53,20 @@ export const noPayloadActionCreatorFactory = (type: string): NoPayloadActionCrea
return { create };
};
+export interface NoPayloadActionCreatorMock extends NoPayloadActionCreator {
+ calls: number;
+}
+
+export const getNoPayloadActionCreatorMock = (creator: NoPayloadActionCreator): NoPayloadActionCreatorMock => {
+ const mock: NoPayloadActionCreatorMock = Object.assign(
+ (): ActionOf => {
+ mock.calls++;
+ return { type: creator.type, payload: undefined };
+ },
+ { type: creator.type, calls: 0 }
+ );
+ return mock;
+};
+
// Should only be used by tests
export const resetAllActionCreatorTypes = () => (allActionCreators.length = 0);
diff --git a/public/app/core/redux/index.ts b/public/app/core/redux/index.ts
index 359f160b9ce..e5087123c1e 100644
--- a/public/app/core/redux/index.ts
+++ b/public/app/core/redux/index.ts
@@ -1,4 +1,2 @@
-import { actionCreatorFactory } from './actionCreatorFactory';
-import { reducerFactory } from './reducerFactory';
-
-export { actionCreatorFactory, reducerFactory };
+export * from './actionCreatorFactory';
+export * from './reducerFactory';
diff --git a/public/app/core/services/__mocks__/backend_srv.ts b/public/app/core/services/__mocks__/backend_srv.ts
new file mode 100644
index 00000000000..cbb04ebf9df
--- /dev/null
+++ b/public/app/core/services/__mocks__/backend_srv.ts
@@ -0,0 +1,14 @@
+
+const backendSrv = {
+ get: jest.fn(),
+ getDashboard: jest.fn(),
+ getDashboardByUid: jest.fn(),
+ getFolderByUid: jest.fn(),
+ post: jest.fn(),
+};
+
+export function getBackendSrv() {
+ return backendSrv;
+}
+
+
diff --git a/public/app/core/services/bridge_srv.ts b/public/app/core/services/bridge_srv.ts
index 37f71946364..8bb828310cf 100644
--- a/public/app/core/services/bridge_srv.ts
+++ b/public/app/core/services/bridge_srv.ts
@@ -46,6 +46,10 @@ export class BridgeSrv {
if (angularUrl !== url) {
this.$timeout(() => {
this.$location.url(url);
+ // some state changes should not trigger new browser history
+ if (state.location.replace) {
+ this.$location.replace();
+ }
});
console.log('store updating angular $location.url', url);
}
diff --git a/public/app/core/services/keybindingSrv.ts b/public/app/core/services/keybindingSrv.ts
index ed321c6a69e..917d1801c0e 100644
--- a/public/app/core/services/keybindingSrv.ts
+++ b/public/app/core/services/keybindingSrv.ts
@@ -104,7 +104,7 @@ export class KeybindingSrv {
}
if (search.fullscreen) {
- this.$rootScope.appEvent('panel-change-view', { fullscreen: false, edit: false });
+ appEvents.emit('panel-change-view', { fullscreen: false, edit: false });
return;
}
@@ -174,7 +174,7 @@ export class KeybindingSrv {
// edit panel
this.bind('e', () => {
if (dashboard.meta.focusPanelId && dashboard.meta.canEdit) {
- this.$rootScope.appEvent('panel-change-view', {
+ appEvents.emit('panel-change-view', {
fullscreen: true,
edit: true,
panelId: dashboard.meta.focusPanelId,
@@ -186,7 +186,7 @@ export class KeybindingSrv {
// view panel
this.bind('v', () => {
if (dashboard.meta.focusPanelId) {
- this.$rootScope.appEvent('panel-change-view', {
+ appEvents.emit('panel-change-view', {
fullscreen: true,
edit: null,
panelId: dashboard.meta.focusPanelId,
@@ -212,9 +212,7 @@ export class KeybindingSrv {
// delete panel
this.bind('p r', () => {
if (dashboard.meta.focusPanelId && dashboard.meta.canEdit) {
- this.$rootScope.appEvent('panel-remove', {
- panelId: dashboard.meta.focusPanelId,
- });
+ appEvents.emit('remove-panel', dashboard.meta.focusPanelId);
dashboard.meta.focusPanelId = 0;
}
});
diff --git a/public/app/core/utils/ConfigProvider.tsx b/public/app/core/utils/ConfigProvider.tsx
index 6883401ad27..cb3ad88b191 100644
--- a/public/app/core/utils/ConfigProvider.tsx
+++ b/public/app/core/utils/ConfigProvider.tsx
@@ -1,6 +1,6 @@
import React from 'react';
import config, { Settings } from 'app/core/config';
-import { GrafanaTheme } from '@grafana/ui';
+import { GrafanaThemeType, ThemeContext, getTheme } from '@grafana/ui';
export const ConfigContext = React.createContext(config);
export const ConfigConsumer = ConfigContext.Consumer;
@@ -13,16 +13,20 @@ export const provideConfig = (component: React.ComponentType) => {
return ConfigProvider;
};
-interface ThemeProviderProps {
- children: (theme: GrafanaTheme) => JSX.Element;
-}
+export const getCurrentThemeName = () =>
+ config.bootData.user.lightTheme ? GrafanaThemeType.Light : GrafanaThemeType.Dark;
+export const getCurrentTheme = () => getTheme(getCurrentThemeName());
-export const ThemeProvider = ({ children }: ThemeProviderProps) => {
+export const ThemeProvider = ({ children }: { children: React.ReactNode }) => {
return (
- {({ bootData }) => {
- return children(bootData.user.lightTheme ? GrafanaTheme.Light : GrafanaTheme.Dark);
+ {config => {
+ return {children} ;
}}
);
};
+
+export const provideTheme = (component: React.ComponentType) => {
+ return provideConfig((props: any) => {React.createElement(component, { ...props })} );
+};
diff --git a/public/app/core/utils/errors.ts b/public/app/core/utils/errors.ts
new file mode 100644
index 00000000000..3f6f1cfbc8d
--- /dev/null
+++ b/public/app/core/utils/errors.ts
@@ -0,0 +1,17 @@
+import _ from 'lodash';
+
+export function getMessageFromError(err: any): string | null {
+ if (err && !_.isString(err)) {
+ if (err.message) {
+ return err.message;
+ } else if (err.data && err.data.message) {
+ return err.data.message;
+ } else if (err.statusText) {
+ return err.statusText;
+ } else {
+ return JSON.stringify(err);
+ }
+ }
+
+ return null;
+}
diff --git a/public/app/core/utils/location_util.ts b/public/app/core/utils/location_util.ts
index 76f2fc5881f..15e1c275550 100644
--- a/public/app/core/utils/location_util.ts
+++ b/public/app/core/utils/location_util.ts
@@ -1,6 +1,6 @@
import config from 'app/core/config';
-export const stripBaseFromUrl = url => {
+export const stripBaseFromUrl = (url: string): string => {
const appSubUrl = config.appSubUrl;
const stripExtraChars = appSubUrl.endsWith('/') ? 1 : 0;
const urlWithoutBase =
diff --git a/public/app/core/utils/react2angular.ts b/public/app/core/utils/react2angular.ts
index 1057f68fcda..eb4bccab267 100644
--- a/public/app/core/utils/react2angular.ts
+++ b/public/app/core/utils/react2angular.ts
@@ -1,11 +1,11 @@
import coreModule from 'app/core/core_module';
-import { provideConfig } from 'app/core/utils/ConfigProvider';
+import { provideTheme } from 'app/core/utils/ConfigProvider';
export function react2AngularDirective(name: string, component: any, options: any) {
coreModule.directive(name, [
'reactDirective',
reactDirective => {
- return reactDirective(provideConfig(component), options);
+ return reactDirective(provideTheme(component), options);
},
]);
}
diff --git a/public/app/core/utils/version.ts b/public/app/core/utils/version.ts
index 1131e1d2ab8..746de761fa3 100644
--- a/public/app/core/utils/version.ts
+++ b/public/app/core/utils/version.ts
@@ -20,12 +20,25 @@ export class SemVersion {
isGtOrEq(version: string): boolean {
const compared = new SemVersion(version);
- return !(this.major < compared.major || this.minor < compared.minor || this.patch < compared.patch);
+
+ for (let i = 0; i < this.comparable.length; ++i) {
+ if (this.comparable[i] > compared.comparable[i]) {
+ return true;
+ }
+ if (this.comparable[i] < compared.comparable[i]) {
+ return false;
+ }
+ }
+ return true;
}
isValid(): boolean {
return _.isNumber(this.major);
}
+
+ get comparable() {
+ return [this.major, this.minor, this.patch];
+ }
}
export function isVersionGtOrEq(a: string, b: string): boolean {
diff --git a/public/app/features/all.ts b/public/app/features/all.ts
index 83146596ea0..d5e684e4a4e 100644
--- a/public/app/features/all.ts
+++ b/public/app/features/all.ts
@@ -12,3 +12,4 @@ import './manage-dashboards';
import './teams/CreateTeamCtrl';
import './profile/all';
import './datasources/settings/HttpSettingsCtrl';
+import './datasources/settings/TlsAuthSettingsCtrl';
diff --git a/public/app/features/annotations/editor_ctrl.ts b/public/app/features/annotations/editor_ctrl.ts
index 18b00793ff8..c12e442f6d3 100644
--- a/public/app/features/annotations/editor_ctrl.ts
+++ b/public/app/features/annotations/editor_ctrl.ts
@@ -2,6 +2,7 @@ import angular from 'angular';
import _ from 'lodash';
import $ from 'jquery';
import coreModule from 'app/core/core_module';
+import { DashboardModel } from 'app/features/dashboard/state';
export class AnnotationsEditorCtrl {
mode: any;
@@ -10,6 +11,7 @@ export class AnnotationsEditorCtrl {
currentAnnotation: any;
currentDatasource: any;
currentIsNew: any;
+ dashboard: DashboardModel;
annotationDefaults: any = {
name: '',
@@ -26,9 +28,10 @@ export class AnnotationsEditorCtrl {
constructor($scope, private datasourceSrv) {
$scope.ctrl = this;
+ this.dashboard = $scope.dashboard;
this.mode = 'list';
this.datasources = datasourceSrv.getAnnotationSources();
- this.annotations = $scope.dashboard.annotations.list;
+ this.annotations = this.dashboard.annotations.list;
this.reset();
this.onColorChange = this.onColorChange.bind(this);
@@ -78,11 +81,13 @@ export class AnnotationsEditorCtrl {
this.annotations.push(this.currentAnnotation);
this.reset();
this.mode = 'list';
+ this.dashboard.updateSubmenuVisibility();
}
removeAnnotation(annotation) {
const index = _.indexOf(this.annotations, annotation);
this.annotations.splice(index, 1);
+ this.dashboard.updateSubmenuVisibility();
}
onColorChange(newColor) {
diff --git a/public/app/features/dashboard/components/AdHocFilters/AdHocFiltersCtrl.ts b/public/app/features/dashboard/components/AdHocFilters/AdHocFiltersCtrl.ts
index 0ceac9ddbba..a7616e0e513 100644
--- a/public/app/features/dashboard/components/AdHocFilters/AdHocFiltersCtrl.ts
+++ b/public/app/features/dashboard/components/AdHocFilters/AdHocFiltersCtrl.ts
@@ -1,10 +1,12 @@
import _ from 'lodash';
import angular from 'angular';
import coreModule from 'app/core/core_module';
+import { DashboardModel } from 'app/features/dashboard/state';
export class AdHocFiltersCtrl {
segments: any;
variable: any;
+ dashboard: DashboardModel;
removeTagFilterSegment: any;
/** @ngInject */
@@ -14,14 +16,13 @@ export class AdHocFiltersCtrl {
private $q,
private variableSrv,
$scope,
- private $rootScope
) {
this.removeTagFilterSegment = uiSegmentSrv.newSegment({
fake: true,
value: '-- remove filter --',
});
this.buildSegmentModel();
- this.$rootScope.onAppEvent('template-variable-value-updated', this.buildSegmentModel.bind(this), $scope);
+ this.dashboard.events.on('template-variable-value-updated', this.buildSegmentModel.bind(this), $scope);
}
buildSegmentModel() {
@@ -171,6 +172,7 @@ export function adHocFiltersComponent() {
controllerAs: 'ctrl',
scope: {
variable: '=',
+ dashboard: '=',
},
};
}
diff --git a/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.test.tsx b/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.test.tsx
new file mode 100644
index 00000000000..91da066e4cc
--- /dev/null
+++ b/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.test.tsx
@@ -0,0 +1,23 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { AddPanelWidget, Props } from './AddPanelWidget';
+import { DashboardModel, PanelModel } from '../../state';
+
+const setup = (propOverrides?: object) => {
+ const props: Props = {
+ dashboard: {} as DashboardModel,
+ panel: {} as PanelModel,
+ };
+
+ Object.assign(props, propOverrides);
+
+ return shallow( );
+};
+
+describe('Render', () => {
+ it('should render component', () => {
+ const wrapper = setup();
+
+ expect(wrapper).toMatchSnapshot();
+ });
+});
diff --git a/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx b/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx
index dbd2fb1ffeb..135b04a8ac5 100644
--- a/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx
+++ b/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx
@@ -1,12 +1,20 @@
+// Libraries
import React from 'react';
import _ from 'lodash';
+
+// Utils
import config from 'app/core/config';
-import { PanelModel } from '../../state/PanelModel';
-import { DashboardModel } from '../../state/DashboardModel';
import store from 'app/core/store';
-import { LS_PANEL_COPY_KEY } from 'app/core/constants';
-import { updateLocation } from 'app/core/actions';
+
+// Store
import { store as reduxStore } from 'app/store/store';
+import { updateLocation } from 'app/core/actions';
+
+// Types
+import { PanelModel } from '../../state';
+import { DashboardModel } from '../../state';
+import { LS_PANEL_COPY_KEY } from 'app/core/constants';
+import { LocationUpdate } from 'app/types';
export interface Props {
panel: PanelModel;
@@ -46,6 +54,7 @@ export class AddPanelWidget extends React.Component {
copiedPanels.push(pluginCopy);
}
}
+
return _.sortBy(copiedPanels, 'sort');
}
@@ -54,28 +63,7 @@ export class AddPanelWidget extends React.Component {
this.props.dashboard.removePanel(this.props.dashboard.panels[0]);
}
- copyButton(panel) {
- return (
- this.onPasteCopiedPanel(panel)} title={panel.name}>
- Paste copied Panel
-
- );
- }
-
- moveToEdit(panel) {
- reduxStore.dispatch(
- updateLocation({
- query: {
- panelId: panel.id,
- edit: true,
- fullscreen: true,
- },
- partial: true,
- })
- );
- }
-
- onCreateNewPanel = () => {
+ onCreateNewPanel = (tab = 'queries') => {
const dashboard = this.props.dashboard;
const { gridPos } = this.props.panel;
@@ -88,7 +76,21 @@ export class AddPanelWidget extends React.Component {
dashboard.addPanel(newPanel);
dashboard.removePanel(this.props.panel);
- this.moveToEdit(newPanel);
+ const location: LocationUpdate = {
+ query: {
+ panelId: newPanel.id,
+ edit: true,
+ fullscreen: true,
+ },
+ partial: true,
+ };
+
+ if (tab === 'visualization') {
+ location.query.tab = 'visualization';
+ location.query.openVizPicker = true;
+ }
+
+ reduxStore.dispatch(updateLocation(location));
};
onPasteCopiedPanel = panelPluginInfo => {
@@ -125,30 +127,50 @@ export class AddPanelWidget extends React.Component {
dashboard.removePanel(this.props.panel);
};
- render() {
- let addCopyButton;
+ renderOptionLink = (icon, text, onClick) => {
+ return (
+
+ );
+ };
- if (this.state.copiedPanelPlugins.length === 1) {
- addCopyButton = this.copyButton(this.state.copiedPanelPlugins[0]);
- }
+ render() {
+ const { copiedPanelPlugins } = this.state;
return (
+ New Panel
-
- Edit Panel
-
- {addCopyButton}
-
- Add Row
-
+
+ {this.renderOptionLink('queries', 'Add Query', this.onCreateNewPanel)}
+ {this.renderOptionLink('visualization', 'Choose Visualization', () =>
+ this.onCreateNewPanel('visualization')
+ )}
+
+
+ Convert to row
+ {copiedPanelPlugins.length === 1 && (
+ this.onPasteCopiedPanel(copiedPanelPlugins[0])}
+ >
+ Paste copied panel
+
+ )}
+
diff --git a/public/app/features/dashboard/components/AddPanelWidget/_AddPanelWidget.scss b/public/app/features/dashboard/components/AddPanelWidget/_AddPanelWidget.scss
index 5a1cbee4b44..288b2e7a410 100644
--- a/public/app/features/dashboard/components/AddPanelWidget/_AddPanelWidget.scss
+++ b/public/app/features/dashboard/components/AddPanelWidget/_AddPanelWidget.scss
@@ -14,6 +14,9 @@
align-items: center;
width: 100%;
cursor: move;
+ background: $page-header-bg;
+ box-shadow: $page-header-shadow;
+ border-bottom: 1px solid $page-header-border-color;
.gicon {
font-size: 30px;
@@ -26,6 +29,29 @@
}
}
+.add-panel-widget__title {
+ font-size: $font-size-md;
+ font-weight: $font-weight-semi-bold;
+ margin-right: $spacer*2;
+}
+
+.add-panel-widget__link {
+ margin: 0 8px;
+ width: 154px;
+}
+
+.add-panel-widget__icon {
+ margin-bottom: 8px;
+
+ .gicon {
+ color: white;
+ height: 44px;
+ width: 53px;
+ position: relative;
+ left: 5px;
+ }
+}
+
.add-panel-widget__close {
margin-left: auto;
background-color: transparent;
@@ -34,14 +60,25 @@
margin-right: -10px;
}
+.add-panel-widget__create {
+ display: inherit;
+ margin-bottom: 24px;
+ // this is to have the big button appear centered
+ margin-top: 55px;
+}
+
+.add-panel-widget__actions {
+ display: inherit;
+}
+
+.add-panel-widget__action {
+ margin: 0 4px;
+}
+
.add-panel-widget__btn-container {
+ height: 100%;
display: flex;
justify-content: center;
align-items: center;
- height: 100%;
flex-direction: column;
-
- .btn {
- margin-bottom: 10px;
- }
}
diff --git a/public/app/features/dashboard/components/AddPanelWidget/__snapshots__/AddPanelWidget.test.tsx.snap b/public/app/features/dashboard/components/AddPanelWidget/__snapshots__/AddPanelWidget.test.tsx.snap
new file mode 100644
index 00000000000..00faf48d8df
--- /dev/null
+++ b/public/app/features/dashboard/components/AddPanelWidget/__snapshots__/AddPanelWidget.test.tsx.snap
@@ -0,0 +1,86 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Render should render component 1`] = `
+
+
+
+
+
+ New Panel
+
+
+
+
+
+
+
+
+
+ Convert to row
+
+
+
+
+
+`;
diff --git a/public/app/features/dashboard/components/DashLinks/DashLinksEditorCtrl.ts b/public/app/features/dashboard/components/DashLinks/DashLinksEditorCtrl.ts
index 398ad757bf3..339c8e7de4c 100644
--- a/public/app/features/dashboard/components/DashLinks/DashLinksEditorCtrl.ts
+++ b/public/app/features/dashboard/components/DashLinks/DashLinksEditorCtrl.ts
@@ -1,5 +1,6 @@
import angular from 'angular';
import _ from 'lodash';
+import { DashboardModel } from 'app/features/dashboard/state';
export let iconMap = {
'external link': 'fa-external-link',
@@ -12,7 +13,7 @@ export let iconMap = {
};
export class DashLinksEditorCtrl {
- dashboard: any;
+ dashboard: DashboardModel;
iconMap: any;
mode: any;
link: any;
@@ -40,6 +41,7 @@ export class DashLinksEditorCtrl {
addLink() {
this.dashboard.links.push(this.link);
this.mode = 'list';
+ this.dashboard.updateSubmenuVisibility();
}
editLink(link) {
diff --git a/public/app/features/dashboard/components/DashNav/DashNav.tsx b/public/app/features/dashboard/components/DashNav/DashNav.tsx
new file mode 100644
index 00000000000..297d7ca7ea7
--- /dev/null
+++ b/public/app/features/dashboard/components/DashNav/DashNav.tsx
@@ -0,0 +1,253 @@
+// Libaries
+import React, { PureComponent } from 'react';
+import { connect } from 'react-redux';
+
+// Utils & Services
+import { AngularComponent, getAngularLoader } from 'app/core/services/AngularLoader';
+import { appEvents } from 'app/core/app_events';
+import { PlaylistSrv } from 'app/features/playlist/playlist_srv';
+
+// Components
+import { DashNavButton } from './DashNavButton';
+
+// State
+import { updateLocation } from 'app/core/actions';
+
+// Types
+import { DashboardModel } from '../../state/DashboardModel';
+
+export interface Props {
+ dashboard: DashboardModel;
+ editview: string;
+ isEditing: boolean;
+ isFullscreen: boolean;
+ $injector: any;
+ updateLocation: typeof updateLocation;
+ onAddPanel: () => void;
+}
+
+export class DashNav extends PureComponent {
+ timePickerEl: HTMLElement;
+ timepickerCmp: AngularComponent;
+ playlistSrv: PlaylistSrv;
+
+ constructor(props: Props) {
+ super(props);
+
+ this.playlistSrv = this.props.$injector.get('playlistSrv');
+ }
+
+ componentDidMount() {
+ const loader = getAngularLoader();
+
+ const template =
+ ' ';
+ const scopeProps = { dashboard: this.props.dashboard };
+
+ this.timepickerCmp = loader.load(this.timePickerEl, scopeProps, template);
+ }
+
+ componentWillUnmount() {
+ if (this.timepickerCmp) {
+ this.timepickerCmp.destroy();
+ }
+ }
+
+ onOpenSearch = () => {
+ appEvents.emit('show-dash-search');
+ };
+
+ onClose = () => {
+ if (this.props.editview) {
+ this.props.updateLocation({
+ query: { editview: null },
+ partial: true,
+ });
+ } else {
+ this.props.updateLocation({
+ query: { panelId: null, edit: null, fullscreen: null },
+ partial: true,
+ });
+ }
+ };
+
+ onToggleTVMode = () => {
+ appEvents.emit('toggle-kiosk-mode');
+ };
+
+ onSave = () => {
+ const { $injector } = this.props;
+ const dashboardSrv = $injector.get('dashboardSrv');
+ dashboardSrv.saveDashboard();
+ };
+
+ onOpenSettings = () => {
+ this.props.updateLocation({
+ query: { editview: 'settings' },
+ partial: true,
+ });
+ };
+
+ onStarDashboard = () => {
+ const { dashboard, $injector } = this.props;
+ const dashboardSrv = $injector.get('dashboardSrv');
+
+ dashboardSrv.starDashboard(dashboard.id, dashboard.meta.isStarred).then(newState => {
+ dashboard.meta.isStarred = newState;
+ this.forceUpdate();
+ });
+ };
+
+ onPlaylistPrev = () => {
+ this.playlistSrv.prev();
+ };
+
+ onPlaylistNext = () => {
+ this.playlistSrv.next();
+ };
+
+ onPlaylistStop = () => {
+ this.playlistSrv.stop();
+ this.forceUpdate();
+ };
+
+ onOpenShare = () => {
+ const $rootScope = this.props.$injector.get('$rootScope');
+ const modalScope = $rootScope.$new();
+ modalScope.tabIndex = 0;
+ modalScope.dashboard = this.props.dashboard;
+
+ appEvents.emit('show-modal', {
+ src: 'public/app/features/dashboard/components/ShareModal/template.html',
+ scope: modalScope,
+ });
+ };
+
+ render() {
+ const { dashboard, isFullscreen, editview, onAddPanel } = this.props;
+ const { canStar, canSave, canShare, folderTitle, showSettings, isStarred } = dashboard.meta;
+ const { snapshot } = dashboard;
+
+ const haveFolder = dashboard.meta.folderId > 0;
+ const snapshotUrl = snapshot && snapshot.originalUrl;
+
+ return (
+
+
+
+
+
+ {this.playlistSrv.isPlaying && (
+
+
+
+
+
+ )}
+
+
+ {canSave && (
+
+ )}
+
+ {canStar && (
+
+ )}
+
+ {canShare && (
+
+ )}
+
+ {canSave && (
+
+ )}
+
+ {snapshotUrl && (
+
+ )}
+
+ {showSettings && (
+
+ )}
+
+
+
+
+
+
+
(this.timePickerEl = element)} />
+
+ {(isFullscreen || editview) && (
+
+
+
+ )}
+
+ );
+ }
+}
+
+const mapStateToProps = () => ({});
+
+const mapDispatchToProps = {
+ updateLocation,
+};
+
+export default connect(mapStateToProps, mapDispatchToProps)(DashNav);
diff --git a/public/app/features/dashboard/components/DashNav/DashNavButton.tsx b/public/app/features/dashboard/components/DashNav/DashNavButton.tsx
new file mode 100644
index 00000000000..505baaf1f5d
--- /dev/null
+++ b/public/app/features/dashboard/components/DashNav/DashNavButton.tsx
@@ -0,0 +1,33 @@
+// Libraries
+import React, { FunctionComponent } from 'react';
+
+// Components
+import { Tooltip } from '@grafana/ui';
+
+interface Props {
+ icon: string;
+ tooltip: string;
+ classSuffix: string;
+ onClick?: () => void;
+ href?: string;
+}
+
+export const DashNavButton: FunctionComponent
= ({ icon, tooltip, classSuffix, onClick, href }) => {
+ if (onClick) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ );
+};
diff --git a/public/app/features/dashboard/components/DashNav/DashNavCtrl.ts b/public/app/features/dashboard/components/DashNav/DashNavCtrl.ts
deleted file mode 100644
index e75c1468a1f..00000000000
--- a/public/app/features/dashboard/components/DashNav/DashNavCtrl.ts
+++ /dev/null
@@ -1,119 +0,0 @@
-import moment from 'moment';
-import angular from 'angular';
-import { appEvents, NavModel } from 'app/core/core';
-import { DashboardModel } from '../../state/DashboardModel';
-
-export class DashNavCtrl {
- dashboard: DashboardModel;
- navModel: NavModel;
- titleTooltip: string;
-
- /** @ngInject */
- constructor(private $scope, private dashboardSrv, private $location, public playlistSrv) {
- appEvents.on('save-dashboard', this.saveDashboard.bind(this), $scope);
-
- if (this.dashboard.meta.isSnapshot) {
- const meta = this.dashboard.meta;
- this.titleTooltip = 'Created: ' + moment(meta.created).calendar();
- if (meta.expires) {
- this.titleTooltip += ' Expires: ' + moment(meta.expires).fromNow() + ' ';
- }
- }
- }
-
- toggleSettings() {
- const search = this.$location.search();
- if (search.editview) {
- delete search.editview;
- } else {
- search.editview = 'settings';
- }
- this.$location.search(search);
- }
-
- toggleViewMode() {
- appEvents.emit('toggle-kiosk-mode');
- }
-
- close() {
- const search = this.$location.search();
- if (search.editview) {
- delete search.editview;
- } else if (search.fullscreen) {
- delete search.fullscreen;
- delete search.edit;
- delete search.tab;
- delete search.panelId;
- }
- this.$location.search(search);
- }
-
- starDashboard() {
- this.dashboardSrv.starDashboard(this.dashboard.id, this.dashboard.meta.isStarred).then(newState => {
- this.dashboard.meta.isStarred = newState;
- });
- }
-
- shareDashboard(tabIndex) {
- const modalScope = this.$scope.$new();
- modalScope.tabIndex = tabIndex;
- modalScope.dashboard = this.dashboard;
-
- appEvents.emit('show-modal', {
- src: 'public/app/features/dashboard/components/ShareModal/template.html',
- scope: modalScope,
- });
- }
-
- hideTooltip(evt) {
- angular.element(evt.currentTarget).tooltip('hide');
- }
-
- saveDashboard() {
- return this.dashboardSrv.saveDashboard();
- }
-
- showSearch() {
- if (this.dashboard.meta.fullscreen) {
- this.close();
- return;
- }
-
- appEvents.emit('show-dash-search');
- }
-
- addPanel() {
- appEvents.emit('dash-scroll', { animate: true, evt: 0 });
-
- if (this.dashboard.panels.length > 0 && this.dashboard.panels[0].type === 'add-panel') {
- return; // Return if the "Add panel" exists already
- }
-
- this.dashboard.addPanel({
- type: 'add-panel',
- gridPos: { x: 0, y: 0, w: 12, h: 8 },
- title: 'Panel Title',
- });
- }
-
- navItemClicked(navItem, evt) {
- if (navItem.clickHandler) {
- navItem.clickHandler();
- evt.preventDefault();
- }
- }
-}
-
-export function dashNavDirective() {
- return {
- restrict: 'E',
- templateUrl: 'public/app/features/dashboard/components/DashNav/template.html',
- controller: DashNavCtrl,
- bindToController: true,
- controllerAs: 'ctrl',
- transclude: true,
- scope: { dashboard: '=' },
- };
-}
-
-angular.module('grafana.directives').directive('dashnav', dashNavDirective);
diff --git a/public/app/features/dashboard/components/DashNav/index.ts b/public/app/features/dashboard/components/DashNav/index.ts
index 854e32b24d2..be07fd0d2a3 100644
--- a/public/app/features/dashboard/components/DashNav/index.ts
+++ b/public/app/features/dashboard/components/DashNav/index.ts
@@ -1 +1,2 @@
-export { DashNavCtrl } from './DashNavCtrl';
+import DashNav from './DashNav';
+export { DashNav };
diff --git a/public/app/features/dashboard/components/DashNav/template.html b/public/app/features/dashboard/components/DashNav/template.html
deleted file mode 100644
index e50a8cd0bff..00000000000
--- a/public/app/features/dashboard/components/DashNav/template.html
+++ /dev/null
@@ -1,61 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/public/app/features/dashboard/components/DashboardRow/DashboardRow.test.tsx b/public/app/features/dashboard/components/DashboardRow/DashboardRow.test.tsx
index 9ac6a6b74e1..96b673242e4 100644
--- a/public/app/features/dashboard/components/DashboardRow/DashboardRow.test.tsx
+++ b/public/app/features/dashboard/components/DashboardRow/DashboardRow.test.tsx
@@ -9,6 +9,7 @@ describe('DashboardRow', () => {
beforeEach(() => {
dashboardMock = {
toggleRow: jest.fn(),
+ on: jest.fn(),
meta: {
canEdit: true,
},
diff --git a/public/app/features/dashboard/components/DashboardRow/DashboardRow.tsx b/public/app/features/dashboard/components/DashboardRow/DashboardRow.tsx
index e7778a31fdb..bb63cea90ea 100644
--- a/public/app/features/dashboard/components/DashboardRow/DashboardRow.tsx
+++ b/public/app/features/dashboard/components/DashboardRow/DashboardRow.tsx
@@ -18,11 +18,11 @@ export class DashboardRow extends React.Component {
collapsed: this.props.panel.collapsed,
};
- appEvents.on('template-variable-value-updated', this.onVariableUpdated);
+ this.props.dashboard.on('template-variable-value-updated', this.onVariableUpdated);
}
componentWillUnmount() {
- appEvents.off('template-variable-value-updated', this.onVariableUpdated);
+ this.props.dashboard.off('template-variable-value-updated', this.onVariableUpdated);
}
onVariableUpdated = () => {
diff --git a/public/app/features/dashboard/components/DashboardSettings/DashboardSettings.tsx b/public/app/features/dashboard/components/DashboardSettings/DashboardSettings.tsx
new file mode 100644
index 00000000000..8a92c0d69eb
--- /dev/null
+++ b/public/app/features/dashboard/components/DashboardSettings/DashboardSettings.tsx
@@ -0,0 +1,36 @@
+// Libaries
+import React, { PureComponent } from 'react';
+
+// Utils & Services
+import { AngularComponent, getAngularLoader } from 'app/core/services/AngularLoader';
+
+// Types
+import { DashboardModel } from '../../state/DashboardModel';
+
+export interface Props {
+ dashboard: DashboardModel | null;
+}
+
+export class DashboardSettings extends PureComponent {
+ element: HTMLElement;
+ angularCmp: AngularComponent;
+
+ componentDidMount() {
+ const loader = getAngularLoader();
+
+ const template = ' ';
+ const scopeProps = { dashboard: this.props.dashboard };
+
+ this.angularCmp = loader.load(this.element, scopeProps, template);
+ }
+
+ componentWillUnmount() {
+ if (this.angularCmp) {
+ this.angularCmp.destroy();
+ }
+ }
+
+ render() {
+ return this.element = element} />;
+ }
+}
diff --git a/public/app/features/dashboard/components/DashboardSettings/index.ts b/public/app/features/dashboard/components/DashboardSettings/index.ts
index f81b8cdbc67..0a89feada33 100644
--- a/public/app/features/dashboard/components/DashboardSettings/index.ts
+++ b/public/app/features/dashboard/components/DashboardSettings/index.ts
@@ -1 +1,2 @@
export { SettingsCtrl } from './SettingsCtrl';
+export { DashboardSettings } from './DashboardSettings';
diff --git a/public/app/features/dashboard/components/SubMenu/SubMenu.tsx b/public/app/features/dashboard/components/SubMenu/SubMenu.tsx
new file mode 100644
index 00000000000..caef8f2de38
--- /dev/null
+++ b/public/app/features/dashboard/components/SubMenu/SubMenu.tsx
@@ -0,0 +1,36 @@
+// Libaries
+import React, { PureComponent } from 'react';
+
+// Utils & Services
+import { AngularComponent, getAngularLoader } from 'app/core/services/AngularLoader';
+
+// Types
+import { DashboardModel } from '../../state/DashboardModel';
+
+export interface Props {
+ dashboard: DashboardModel | null;
+}
+
+export class SubMenu extends PureComponent
{
+ element: HTMLElement;
+ angularCmp: AngularComponent;
+
+ componentDidMount() {
+ const loader = getAngularLoader();
+
+ const template = ' ';
+ const scopeProps = { dashboard: this.props.dashboard };
+
+ this.angularCmp = loader.load(this.element, scopeProps, template);
+ }
+
+ componentWillUnmount() {
+ if (this.angularCmp) {
+ this.angularCmp.destroy();
+ }
+ }
+
+ render() {
+ return this.element = element} />;
+ }
+}
diff --git a/public/app/features/dashboard/components/SubMenu/index.ts b/public/app/features/dashboard/components/SubMenu/index.ts
index 1790aa66782..ca113ab75d6 100644
--- a/public/app/features/dashboard/components/SubMenu/index.ts
+++ b/public/app/features/dashboard/components/SubMenu/index.ts
@@ -1 +1,2 @@
export { SubMenuCtrl } from './SubMenuCtrl';
+export { SubMenu } from './SubMenu';
diff --git a/public/app/features/dashboard/components/SubMenu/template.html b/public/app/features/dashboard/components/SubMenu/template.html
index 5d0f200d862..1ccbfcc915c 100644
--- a/public/app/features/dashboard/components/SubMenu/template.html
+++ b/public/app/features/dashboard/components/SubMenu/template.html
@@ -7,7 +7,7 @@
-
+
diff --git a/public/app/features/dashboard/containers/DashboardCtrl.ts b/public/app/features/dashboard/containers/DashboardCtrl.ts
deleted file mode 100644
index 74795315504..00000000000
--- a/public/app/features/dashboard/containers/DashboardCtrl.ts
+++ /dev/null
@@ -1,156 +0,0 @@
-// Utils
-import config from 'app/core/config';
-import appEvents from 'app/core/app_events';
-import coreModule from 'app/core/core_module';
-import { removePanel } from 'app/features/dashboard/utils/panel';
-
-// Services
-import { AnnotationsSrv } from '../../annotations/annotations_srv';
-
-// Types
-import { DashboardModel } from '../state/DashboardModel';
-
-export class DashboardCtrl {
- dashboard: DashboardModel;
- dashboardViewState: any;
- loadedFallbackDashboard: boolean;
- editTab: number;
-
- /** @ngInject */
- constructor(
- private $scope,
- private keybindingSrv,
- private timeSrv,
- private variableSrv,
- private dashboardSrv,
- private unsavedChangesSrv,
- private dashboardViewStateSrv,
- private annotationsSrv: AnnotationsSrv,
- public playlistSrv
- ) {
- // temp hack due to way dashboards are loaded
- // can't use controllerAs on route yet
- $scope.ctrl = this;
-
- // TODO: break out settings view to separate view & controller
- this.editTab = 0;
-
- // funcs called from React component bindings and needs this binding
- this.getPanelContainer = this.getPanelContainer.bind(this);
- }
-
- setupDashboard(data) {
- try {
- this.setupDashboardInternal(data);
- } catch (err) {
- this.onInitFailed(err, 'Dashboard init failed', true);
- }
- }
-
- setupDashboardInternal(data) {
- const dashboard = this.dashboardSrv.create(data.dashboard, data.meta);
- this.dashboardSrv.setCurrent(dashboard);
-
- // init services
- this.timeSrv.init(dashboard);
- this.annotationsSrv.init(dashboard);
-
- // template values service needs to initialize completely before
- // the rest of the dashboard can load
- this.variableSrv
- .init(dashboard)
- // template values failes are non fatal
- .catch(this.onInitFailed.bind(this, 'Templating init failed', false))
- // continue
- .finally(() => {
- this.dashboard = dashboard;
- this.dashboard.processRepeats();
- this.dashboard.updateSubmenuVisibility();
- this.dashboard.autoFitPanels(window.innerHeight);
-
- this.unsavedChangesSrv.init(dashboard, this.$scope);
-
- // TODO refactor ViewStateSrv
- this.$scope.dashboard = dashboard;
- this.dashboardViewState = this.dashboardViewStateSrv.create(this.$scope);
-
- this.keybindingSrv.setupDashboardBindings(this.$scope, dashboard);
- this.setWindowTitleAndTheme();
-
- appEvents.emit('dashboard-initialized', dashboard);
- })
- .catch(this.onInitFailed.bind(this, 'Dashboard init failed', true));
- }
-
- onInitFailed(msg, fatal, err) {
- console.log(msg, err);
-
- if (err.data && err.data.message) {
- err.message = err.data.message;
- } else if (!err.message) {
- err = { message: err.toString() };
- }
-
- this.$scope.appEvent('alert-error', [msg, err.message]);
-
- // protect against recursive fallbacks
- if (fatal && !this.loadedFallbackDashboard) {
- this.loadedFallbackDashboard = true;
- this.setupDashboard({ dashboard: { title: 'Dashboard Init failed' } });
- }
- }
-
- templateVariableUpdated() {
- this.dashboard.processRepeats();
- }
-
- setWindowTitleAndTheme() {
- window.document.title = config.windowTitlePrefix + this.dashboard.title;
- }
-
- showJsonEditor(evt, options) {
- const model = {
- object: options.object,
- updateHandler: options.updateHandler,
- };
-
- this.$scope.appEvent('show-dash-editor', {
- src: 'public/app/partials/edit_json.html',
- model: model,
- });
- }
-
- getDashboard() {
- return this.dashboard;
- }
-
- getPanelContainer() {
- return this;
- }
-
- onRemovingPanel(evt, options) {
- options = options || {};
- if (!options.panelId) {
- return;
- }
-
- const panelInfo = this.dashboard.getPanelInfoById(options.panelId);
- removePanel(this.dashboard, panelInfo.panel, true);
- }
-
- onDestroy() {
- if (this.dashboard) {
- this.dashboard.destroy();
- }
- }
-
- init(dashboard) {
- this.$scope.onAppEvent('show-json-editor', this.showJsonEditor.bind(this));
- this.$scope.onAppEvent('template-variable-value-updated', this.templateVariableUpdated.bind(this));
- this.$scope.onAppEvent('panel-remove', this.onRemovingPanel.bind(this));
- this.$scope.$on('$destroy', this.onDestroy.bind(this));
- this.setupDashboard(dashboard);
- }
-}
-
-coreModule.controller('DashboardCtrl', DashboardCtrl);
diff --git a/public/app/features/dashboard/containers/DashboardPage.test.tsx b/public/app/features/dashboard/containers/DashboardPage.test.tsx
new file mode 100644
index 00000000000..1e6f8bd888e
--- /dev/null
+++ b/public/app/features/dashboard/containers/DashboardPage.test.tsx
@@ -0,0 +1,251 @@
+import React from 'react';
+import { shallow, ShallowWrapper } from 'enzyme';
+import { DashboardPage, Props, State } from './DashboardPage';
+import { DashboardModel } from '../state';
+import { cleanUpDashboard } from '../state/actions';
+import { getNoPayloadActionCreatorMock, NoPayloadActionCreatorMock } from 'app/core/redux';
+import { DashboardRouteInfo, DashboardInitPhase } from 'app/types';
+
+jest.mock('sass/_variables.scss', () => ({
+ panelhorizontalpadding: 10,
+ panelVerticalPadding: 10,
+}));
+
+jest.mock('app/features/dashboard/components/DashboardSettings/SettingsCtrl', () => ({}));
+
+interface ScenarioContext {
+ cleanUpDashboardMock: NoPayloadActionCreatorMock;
+ dashboard?: DashboardModel;
+ setDashboardProp: (overrides?: any, metaOverrides?: any) => void;
+ wrapper?: ShallowWrapper
;
+ mount: (propOverrides?: Partial) => void;
+ setup?: (fn: () => void) => void;
+}
+
+function getTestDashboard(overrides?: any, metaOverrides?: any): DashboardModel {
+ const data = Object.assign({
+ title: 'My dashboard',
+ panels: [
+ {
+ id: 1,
+ type: 'graph',
+ title: 'My graph',
+ gridPos: { x: 0, y: 0, w: 1, h: 1 },
+ },
+ ],
+ }, overrides);
+
+ const meta = Object.assign({ canSave: true, canEdit: true }, metaOverrides);
+ return new DashboardModel(data, meta);
+}
+
+function dashboardPageScenario(description, scenarioFn: (ctx: ScenarioContext) => void) {
+ describe(description, () => {
+ let setupFn: () => void;
+
+ const ctx: ScenarioContext = {
+ cleanUpDashboardMock: getNoPayloadActionCreatorMock(cleanUpDashboard),
+ setup: fn => {
+ setupFn = fn;
+ },
+ setDashboardProp: (overrides?: any, metaOverrides?: any) => {
+ ctx.dashboard = getTestDashboard(overrides, metaOverrides);
+ ctx.wrapper.setProps({ dashboard: ctx.dashboard });
+ },
+ mount: (propOverrides?: Partial) => {
+ const props: Props = {
+ urlSlug: 'my-dash',
+ $scope: {},
+ urlUid: '11',
+ $injector: {},
+ routeInfo: DashboardRouteInfo.Normal,
+ urlEdit: false,
+ urlFullscreen: false,
+ initPhase: DashboardInitPhase.NotStarted,
+ isInitSlow: false,
+ initDashboard: jest.fn(),
+ updateLocation: jest.fn(),
+ notifyApp: jest.fn(),
+ cleanUpDashboard: ctx.cleanUpDashboardMock,
+ dashboard: null,
+ };
+
+ Object.assign(props, propOverrides);
+
+ ctx.dashboard = props.dashboard;
+ ctx.wrapper = shallow( );
+ }
+ };
+
+ beforeEach(() => {
+ setupFn();
+ });
+
+ scenarioFn(ctx);
+ });
+}
+
+describe('DashboardPage', () => {
+
+ dashboardPageScenario("Given initial state", (ctx) => {
+ ctx.setup(() => {
+ ctx.mount();
+ });
+
+ it('Should render nothing', () => {
+ expect(ctx.wrapper).toMatchSnapshot();
+ });
+ });
+
+ dashboardPageScenario("Dashboard is fetching slowly", (ctx) => {
+ ctx.setup(() => {
+ ctx.mount();
+ ctx.wrapper.setProps({
+ isInitSlow: true,
+ initPhase: DashboardInitPhase.Fetching,
+ });
+ });
+
+ it('Should render slow init state', () => {
+ expect(ctx.wrapper).toMatchSnapshot();
+ });
+ });
+
+ dashboardPageScenario("Dashboard init completed ", (ctx) => {
+ ctx.setup(() => {
+ ctx.mount();
+ ctx.setDashboardProp();
+ });
+
+ it('Should update title', () => {
+ expect(document.title).toBe('My dashboard - Grafana');
+ });
+
+ it('Should render dashboard grid', () => {
+ expect(ctx.wrapper).toMatchSnapshot();
+ });
+ });
+
+ dashboardPageScenario("When user goes into panel edit", (ctx) => {
+ ctx.setup(() => {
+ ctx.mount();
+ ctx.setDashboardProp();
+ ctx.wrapper.setProps({
+ urlFullscreen: true,
+ urlEdit: true,
+ urlPanelId: '1',
+ });
+ });
+
+ it('Should update model state to fullscreen & edit', () => {
+ expect(ctx.dashboard.meta.fullscreen).toBe(true);
+ expect(ctx.dashboard.meta.isEditing).toBe(true);
+ });
+
+ it('Should update component state to fullscreen and edit', () => {
+ const state = ctx.wrapper.state();
+ expect(state.isEditing).toBe(true);
+ expect(state.isFullscreen).toBe(true);
+ });
+ });
+
+ dashboardPageScenario("When user goes back to dashboard from panel edit", (ctx) => {
+ ctx.setup(() => {
+ ctx.mount();
+ ctx.setDashboardProp();
+ ctx.wrapper.setState({ scrollTop: 100 });
+ ctx.wrapper.setProps({
+ urlFullscreen: true,
+ urlEdit: true,
+ urlPanelId: '1',
+ });
+ ctx.wrapper.setProps({
+ urlFullscreen: false,
+ urlEdit: false,
+ urlPanelId: null,
+ });
+ });
+
+ it('Should update model state normal state', () => {
+ expect(ctx.dashboard.meta.fullscreen).toBe(false);
+ expect(ctx.dashboard.meta.isEditing).toBe(false);
+ });
+
+ it('Should update component state to normal and restore scrollTop', () => {
+ const state = ctx.wrapper.state();
+ expect(state.isEditing).toBe(false);
+ expect(state.isFullscreen).toBe(false);
+ expect(state.scrollTop).toBe(100);
+ });
+ });
+
+ dashboardPageScenario("When dashboard has editview url state", (ctx) => {
+ ctx.setup(() => {
+ ctx.mount();
+ ctx.setDashboardProp();
+ ctx.wrapper.setProps({
+ editview: 'settings',
+ });
+ });
+
+ it('should render settings view', () => {
+ expect(ctx.wrapper).toMatchSnapshot();
+ });
+
+ it('should set animation state', () => {
+ expect(ctx.wrapper.state().isSettingsOpening).toBe(true);
+ });
+ });
+
+ dashboardPageScenario("When adding panel", (ctx) => {
+ ctx.setup(() => {
+ ctx.mount();
+ ctx.setDashboardProp();
+ ctx.wrapper.setState({ scrollTop: 100 });
+ ctx.wrapper.instance().onAddPanel();
+ });
+
+ it('should set scrollTop to 0', () => {
+ expect(ctx.wrapper.state().scrollTop).toBe(0);
+ });
+
+ it('should add panel widget to dashboard panels', () => {
+ expect(ctx.dashboard.panels[0].type).toBe('add-panel');
+ });
+ });
+
+ dashboardPageScenario("Given panel with id 0", (ctx) => {
+ ctx.setup(() => {
+ ctx.mount();
+ ctx.setDashboardProp({
+ panels: [{ id: 0, type: 'graph'}],
+ schemaVersion: 17,
+ });
+ ctx.wrapper.setProps({
+ urlEdit: true,
+ urlFullscreen: true,
+ urlPanelId: '0'
+ });
+ });
+
+ it('Should go into edit mode' , () => {
+ expect(ctx.wrapper.state().isEditing).toBe(true);
+ expect(ctx.wrapper.state().fullscreenPanel.id).toBe(0);
+ });
+ });
+
+ dashboardPageScenario("When dashboard unmounts", (ctx) => {
+ ctx.setup(() => {
+ ctx.mount();
+ ctx.setDashboardProp({
+ panels: [{ id: 0, type: 'graph'}],
+ schemaVersion: 17,
+ });
+ ctx.wrapper.unmount();
+ });
+
+ it('Should call clean up action' , () => {
+ expect(ctx.cleanUpDashboardMock.calls).toBe(1);
+ });
+ });
+});
diff --git a/public/app/features/dashboard/containers/DashboardPage.tsx b/public/app/features/dashboard/containers/DashboardPage.tsx
new file mode 100644
index 00000000000..27118e297b5
--- /dev/null
+++ b/public/app/features/dashboard/containers/DashboardPage.tsx
@@ -0,0 +1,309 @@
+// Libraries
+import $ from 'jquery';
+import React, { PureComponent, MouseEvent } from 'react';
+import { hot } from 'react-hot-loader';
+import { connect } from 'react-redux';
+import classNames from 'classnames';
+
+// Services & Utils
+import { createErrorNotification } from 'app/core/copy/appNotification';
+import { getMessageFromError } from 'app/core/utils/errors';
+
+// Components
+import { DashboardGrid } from '../dashgrid/DashboardGrid';
+import { DashNav } from '../components/DashNav';
+import { SubMenu } from '../components/SubMenu';
+import { DashboardSettings } from '../components/DashboardSettings';
+import { CustomScrollbar } from '@grafana/ui';
+import { AlertBox } from 'app/core/components/AlertBox/AlertBox';
+
+// Redux
+import { initDashboard } from '../state/initDashboard';
+import { cleanUpDashboard } from '../state/actions';
+import { updateLocation } from 'app/core/actions';
+import { notifyApp } from 'app/core/actions';
+
+// Types
+import {
+ StoreState,
+ DashboardInitPhase,
+ DashboardRouteInfo,
+ DashboardInitError,
+ AppNotificationSeverity,
+} from 'app/types';
+import { DashboardModel, PanelModel } from 'app/features/dashboard/state';
+
+export interface Props {
+ urlUid?: string;
+ urlSlug?: string;
+ urlType?: string;
+ editview?: string;
+ urlPanelId?: string;
+ urlFolderId?: string;
+ $scope: any;
+ $injector: any;
+ routeInfo: DashboardRouteInfo;
+ urlEdit: boolean;
+ urlFullscreen: boolean;
+ initPhase: DashboardInitPhase;
+ isInitSlow: boolean;
+ dashboard: DashboardModel | null;
+ initError?: DashboardInitError;
+ initDashboard: typeof initDashboard;
+ cleanUpDashboard: typeof cleanUpDashboard;
+ notifyApp: typeof notifyApp;
+ updateLocation: typeof updateLocation;
+}
+
+export interface State {
+ isSettingsOpening: boolean;
+ isEditing: boolean;
+ isFullscreen: boolean;
+ fullscreenPanel: PanelModel | null;
+ scrollTop: number;
+ rememberScrollTop: number;
+ showLoadingState: boolean;
+}
+
+export class DashboardPage extends PureComponent {
+ state: State = {
+ isSettingsOpening: false,
+ isEditing: false,
+ isFullscreen: false,
+ showLoadingState: false,
+ fullscreenPanel: null,
+ scrollTop: 0,
+ rememberScrollTop: 0,
+ };
+
+ async componentDidMount() {
+ this.props.initDashboard({
+ $injector: this.props.$injector,
+ $scope: this.props.$scope,
+ urlSlug: this.props.urlSlug,
+ urlUid: this.props.urlUid,
+ urlType: this.props.urlType,
+ urlFolderId: this.props.urlFolderId,
+ routeInfo: this.props.routeInfo,
+ fixUrl: true,
+ });
+ }
+
+ componentWillUnmount() {
+ if (this.props.dashboard) {
+ this.props.cleanUpDashboard();
+ }
+ }
+
+ componentDidUpdate(prevProps: Props) {
+ const { dashboard, editview, urlEdit, urlFullscreen, urlPanelId } = this.props;
+
+ if (!dashboard) {
+ return;
+ }
+
+ // if we just got dashboard update title
+ if (!prevProps.dashboard) {
+ document.title = dashboard.title + ' - Grafana';
+ }
+
+ // handle animation states when opening dashboard settings
+ if (!prevProps.editview && editview) {
+ this.setState({ isSettingsOpening: true });
+ setTimeout(() => {
+ this.setState({ isSettingsOpening: false });
+ }, 10);
+ }
+
+ // Sync url state with model
+ if (urlFullscreen !== dashboard.meta.fullscreen || urlEdit !== dashboard.meta.isEditing) {
+ if (!isNaN(parseInt(urlPanelId, 10))) {
+ this.onEnterFullscreen();
+ } else {
+ this.onLeaveFullscreen();
+ }
+ }
+ }
+
+ onEnterFullscreen() {
+ const { dashboard, urlEdit, urlFullscreen, urlPanelId } = this.props;
+
+ const panelId = parseInt(urlPanelId, 10);
+
+ // need to expand parent row if this panel is inside a row
+ dashboard.expandParentRowFor(panelId);
+
+ const panel = dashboard.getPanelById(panelId);
+
+ if (panel) {
+ dashboard.setViewMode(panel, urlFullscreen, urlEdit);
+ this.setState({
+ isEditing: urlEdit && dashboard.meta.canEdit,
+ isFullscreen: urlFullscreen,
+ fullscreenPanel: panel,
+ rememberScrollTop: this.state.scrollTop,
+ });
+ this.setPanelFullscreenClass(urlFullscreen);
+ } else {
+ this.handleFullscreenPanelNotFound(urlPanelId);
+ }
+ }
+
+ onLeaveFullscreen() {
+ const { dashboard } = this.props;
+
+ if (this.state.fullscreenPanel) {
+ dashboard.setViewMode(this.state.fullscreenPanel, false, false);
+ }
+
+ this.setState(
+ {
+ isEditing: false,
+ isFullscreen: false,
+ fullscreenPanel: null,
+ scrollTop: this.state.rememberScrollTop,
+ },
+ () => {
+ dashboard.render();
+ }
+ );
+
+ this.setPanelFullscreenClass(false);
+ }
+
+ handleFullscreenPanelNotFound(urlPanelId: string) {
+ // Panel not found
+ this.props.notifyApp(createErrorNotification(`Panel with id ${urlPanelId} not found`));
+ // Clear url state
+ this.props.updateLocation({
+ query: {
+ edit: null,
+ fullscreen: null,
+ panelId: null,
+ },
+ partial: true,
+ });
+ }
+
+ setPanelFullscreenClass(isFullscreen: boolean) {
+ $('body').toggleClass('panel-in-fullscreen', isFullscreen);
+ }
+
+ setScrollTop = (e: MouseEvent): void => {
+ const target = e.target as HTMLElement;
+ this.setState({ scrollTop: target.scrollTop });
+ };
+
+ onAddPanel = () => {
+ const { dashboard } = this.props;
+
+ // Return if the "Add panel" exists already
+ if (dashboard.panels.length > 0 && dashboard.panels[0].type === 'add-panel') {
+ return;
+ }
+
+ dashboard.addPanel({
+ type: 'add-panel',
+ gridPos: { x: 0, y: 0, w: 12, h: 8 },
+ title: 'Panel Title',
+ });
+
+ // scroll to top after adding panel
+ this.setState({ scrollTop: 0 });
+ };
+
+ renderSlowInitState() {
+ return (
+
+
+ {this.props.initPhase}
+
+
+ );
+ }
+
+ renderInitFailedState() {
+ const { initError } = this.props;
+
+ return (
+
+ );
+ }
+
+ render() {
+ const { dashboard, editview, $injector, isInitSlow, initError } = this.props;
+ const { isSettingsOpening, isEditing, isFullscreen, scrollTop } = this.state;
+
+ if (!dashboard) {
+ if (isInitSlow) {
+ return this.renderSlowInitState();
+ }
+ return null;
+ }
+
+ const classes = classNames({
+ 'dashboard-page--settings-opening': isSettingsOpening,
+ 'dashboard-page--settings-open': !isSettingsOpening && editview,
+ });
+
+ const gridWrapperClasses = classNames({
+ 'dashboard-container': true,
+ 'dashboard-container--has-submenu': dashboard.meta.submenuEnabled,
+ });
+
+ return (
+
+
+
+
+ {editview && }
+
+ {initError && this.renderInitFailedState()}
+
+
+ {dashboard.meta.submenuEnabled && }
+
+
+
+
+
+ );
+ }
+}
+
+const mapStateToProps = (state: StoreState) => ({
+ urlUid: state.location.routeParams.uid,
+ urlSlug: state.location.routeParams.slug,
+ urlType: state.location.routeParams.type,
+ editview: state.location.query.editview,
+ urlPanelId: state.location.query.panelId,
+ urlFolderId: state.location.query.folderId,
+ urlFullscreen: state.location.query.fullscreen === true,
+ urlEdit: state.location.query.edit === true,
+ initPhase: state.dashboard.initPhase,
+ isInitSlow: state.dashboard.isInitSlow,
+ initError: state.dashboard.initError,
+ dashboard: state.dashboard.model as DashboardModel,
+});
+
+const mapDispatchToProps = {
+ initDashboard,
+ cleanUpDashboard,
+ notifyApp,
+ updateLocation,
+};
+
+export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(DashboardPage));
diff --git a/public/app/features/dashboard/containers/SoloPanelPage.tsx b/public/app/features/dashboard/containers/SoloPanelPage.tsx
index 097c8015929..6dcf2775547 100644
--- a/public/app/features/dashboard/containers/SoloPanelPage.tsx
+++ b/public/app/features/dashboard/containers/SoloPanelPage.tsx
@@ -3,98 +3,84 @@ import React, { Component } from 'react';
import { hot } from 'react-hot-loader';
import { connect } from 'react-redux';
-// Utils & Services
-import appEvents from 'app/core/app_events';
-import locationUtil from 'app/core/utils/location_util';
-import { getBackendSrv } from 'app/core/services/backend_srv';
-
// Components
import { DashboardPanel } from '../dashgrid/DashboardPanel';
// Redux
-import { updateLocation } from 'app/core/actions';
+import { initDashboard } from '../state/initDashboard';
// Types
-import { StoreState } from 'app/types';
+import { StoreState, DashboardRouteInfo } from 'app/types';
import { PanelModel, DashboardModel } from 'app/features/dashboard/state';
interface Props {
- panelId: string;
+ urlPanelId: string;
urlUid?: string;
urlSlug?: string;
urlType?: string;
$scope: any;
$injector: any;
- updateLocation: typeof updateLocation;
+ routeInfo: DashboardRouteInfo;
+ initDashboard: typeof initDashboard;
+ dashboard: DashboardModel | null;
}
interface State {
panel: PanelModel | null;
- dashboard: DashboardModel | null;
notFound: boolean;
}
export class SoloPanelPage extends Component {
-
state: State = {
panel: null,
- dashboard: null,
notFound: false,
};
componentDidMount() {
- const { $injector, $scope, urlUid, urlType, urlSlug } = this.props;
+ const { $injector, $scope, urlUid, urlType, urlSlug, routeInfo } = this.props;
- // handle old urls with no uid
- if (!urlUid && !(urlType === 'script' || urlType === 'snapshot')) {
- this.redirectToNewUrl();
- return;
- }
-
- const dashboardLoaderSrv = $injector.get('dashboardLoaderSrv');
-
- // subscribe to event to know when dashboard controller is done with inititalization
- appEvents.on('dashboard-initialized', this.onDashoardInitialized);
-
- dashboardLoaderSrv.loadDashboard(urlType, urlSlug, urlUid).then(result => {
- result.meta.soloMode = true;
- $scope.initDashboard(result, $scope);
+ this.props.initDashboard({
+ $injector: $injector,
+ $scope: $scope,
+ urlSlug: urlSlug,
+ urlUid: urlUid,
+ urlType: urlType,
+ routeInfo: routeInfo,
+ fixUrl: false,
});
}
- redirectToNewUrl() {
- getBackendSrv().getDashboardBySlug(this.props.urlSlug).then(res => {
- if (res) {
- const url = locationUtil.stripBaseFromUrl(res.meta.url.replace('/d/', '/d-solo/'));
- this.props.updateLocation(url);
+ componentDidUpdate(prevProps: Props) {
+ const { urlPanelId, dashboard } = this.props;
+
+ if (!dashboard) {
+ return;
+ }
+
+ // we just got the dashboard!
+ if (!prevProps.dashboard) {
+ const panelId = parseInt(urlPanelId, 10);
+
+ // need to expand parent row if this panel is inside a row
+ dashboard.expandParentRowFor(panelId);
+
+ const panel = dashboard.getPanelById(panelId);
+
+ if (!panel) {
+ this.setState({ notFound: true });
+ return;
}
- });
- }
- onDashoardInitialized = () => {
- const { $scope, panelId } = this.props;
-
- const dashboard: DashboardModel = $scope.dashboard;
- const panel = dashboard.getPanelById(parseInt(panelId, 10));
-
- if (!panel) {
- this.setState({ notFound: true });
- return;
+ this.setState({ panel });
}
-
- this.setState({ dashboard, panel });
- };
+ }
render() {
- const { panelId } = this.props;
- const { notFound, panel, dashboard } = this.state;
+ const { urlPanelId, dashboard } = this.props;
+ const { notFound, panel } = this.state;
if (notFound) {
- return (
-
- Panel with id { panelId } not found
-
- );
+ return Panel with id {urlPanelId} not found
;
}
if (!panel) {
@@ -113,11 +99,12 @@ const mapStateToProps = (state: StoreState) => ({
urlUid: state.location.routeParams.uid,
urlSlug: state.location.routeParams.slug,
urlType: state.location.routeParams.type,
- panelId: state.location.query.panelId
+ urlPanelId: state.location.query.panelId,
+ dashboard: state.dashboard.model as DashboardModel,
});
const mapDispatchToProps = {
- updateLocation
+ initDashboard,
};
export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(SoloPanelPage));
diff --git a/public/app/features/dashboard/containers/__snapshots__/DashboardPage.test.tsx.snap b/public/app/features/dashboard/containers/__snapshots__/DashboardPage.test.tsx.snap
new file mode 100644
index 00000000000..002cac2306e
--- /dev/null
+++ b/public/app/features/dashboard/containers/__snapshots__/DashboardPage.test.tsx.snap
@@ -0,0 +1,546 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`DashboardPage Dashboard init completed Should render dashboard grid 1`] = `
+
+`;
+
+exports[`DashboardPage Dashboard is fetching slowly Should render slow init state 1`] = `
+
+`;
+
+exports[`DashboardPage Given initial state Should render nothing 1`] = `""`;
+
+exports[`DashboardPage When dashboard has editview url state should render settings view 1`] = `
+
+`;
diff --git a/public/app/features/dashboard/dashgrid/DashboardGrid.tsx b/public/app/features/dashboard/dashgrid/DashboardGrid.tsx
index 658bfad3816..27f699ff3e6 100644
--- a/public/app/features/dashboard/dashgrid/DashboardGrid.tsx
+++ b/public/app/features/dashboard/dashgrid/DashboardGrid.tsx
@@ -1,11 +1,14 @@
-import React from 'react';
+// Libaries
+import React, { PureComponent } from 'react';
import { hot } from 'react-hot-loader';
import ReactGridLayout, { ItemCallback } from 'react-grid-layout';
+import classNames from 'classnames';
+import sizeMe from 'react-sizeme';
+
+// Types
import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN, GRID_COLUMN_COUNT } from 'app/core/constants';
import { DashboardPanel } from './DashboardPanel';
import { DashboardModel, PanelModel } from '../state';
-import classNames from 'classnames';
-import sizeMe from 'react-sizeme';
let lastGridWidth = 1200;
let ignoreNextWidthChange = false;
@@ -76,19 +79,18 @@ function GridWrapper({
const SizedReactLayoutGrid = sizeMe({ monitorWidth: true })(GridWrapper);
-export interface DashboardGridProps {
+export interface Props {
dashboard: DashboardModel;
+ isEditing: boolean;
+ isFullscreen: boolean;
}
-export class DashboardGrid extends React.Component {
+export class DashboardGrid extends PureComponent {
gridToPanelMap: any;
panelMap: { [id: string]: PanelModel };
- constructor(props: DashboardGridProps) {
- super(props);
-
- // subscribe to dashboard events
- const dashboard = this.props.dashboard;
+ componentDidMount() {
+ const { dashboard } = this.props;
dashboard.on('panel-added', this.triggerForceUpdate);
dashboard.on('panel-removed', this.triggerForceUpdate);
dashboard.on('repeats-processed', this.triggerForceUpdate);
@@ -97,6 +99,16 @@ export class DashboardGrid extends React.Component {
dashboard.on('row-expanded', this.triggerForceUpdate);
}
+ componentWillUnmount() {
+ const { dashboard } = this.props;
+ dashboard.off('panel-added', this.triggerForceUpdate);
+ dashboard.off('panel-removed', this.triggerForceUpdate);
+ dashboard.off('repeats-processed', this.triggerForceUpdate);
+ dashboard.off('view-mode-changed', this.onViewModeChanged);
+ dashboard.off('row-collapsed', this.triggerForceUpdate);
+ dashboard.off('row-expanded', this.triggerForceUpdate);
+ }
+
buildLayout() {
const layout = [];
this.panelMap = {};
@@ -151,7 +163,6 @@ export class DashboardGrid extends React.Component {
onViewModeChanged = () => {
ignoreNextWidthChange = true;
- this.forceUpdate();
}
updateGridPos = (item: ReactGridLayout.Layout, layout: ReactGridLayout.Layout[]) => {
@@ -197,18 +208,20 @@ export class DashboardGrid extends React.Component {
}
render() {
+ const { dashboard, isFullscreen } = this.props;
+
return (
{this.renderPanels()}
diff --git a/public/app/features/dashboard/index.ts b/public/app/features/dashboard/index.ts
index 9f2935660ef..d9a03b0aad6 100644
--- a/public/app/features/dashboard/index.ts
+++ b/public/app/features/dashboard/index.ts
@@ -1,8 +1,6 @@
-import './containers/DashboardCtrl';
import './dashgrid/DashboardGridDirective';
// Services
-import './services/DashboardViewStateSrv';
import './services/UnsavedChangesSrv';
import './services/DashboardLoaderSrv';
import './services/DashboardSrv';
diff --git a/public/app/features/dashboard/panel_editor/PanelEditor.tsx b/public/app/features/dashboard/panel_editor/PanelEditor.tsx
index d7aafb89e55..bfdc13bc8f2 100644
--- a/public/app/features/dashboard/panel_editor/PanelEditor.tsx
+++ b/public/app/features/dashboard/panel_editor/PanelEditor.tsx
@@ -2,7 +2,7 @@ import React, { PureComponent } from 'react';
import classNames from 'classnames';
import { QueriesTab } from './QueriesTab';
-import { VisualizationTab } from './VisualizationTab';
+import VisualizationTab from './VisualizationTab';
import { GeneralTab } from './GeneralTab';
import { AlertTab } from '../../alerting/AlertTab';
@@ -38,7 +38,7 @@ export class PanelEditor extends PureComponent {
onChangeTab = (tab: PanelEditorTab) => {
store.dispatch(
updateLocation({
- query: { tab: tab.id },
+ query: { tab: tab.id, openVizPicker: null },
partial: true,
})
);
diff --git a/public/app/features/dashboard/panel_editor/QueryEditorRow.tsx b/public/app/features/dashboard/panel_editor/QueryEditorRow.tsx
index eda10087d41..83ef70f62e7 100644
--- a/public/app/features/dashboard/panel_editor/QueryEditorRow.tsx
+++ b/public/app/features/dashboard/panel_editor/QueryEditorRow.tsx
@@ -7,10 +7,11 @@ import _ from 'lodash';
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
import { AngularComponent, getAngularLoader } from 'app/core/services/AngularLoader';
import { Emitter } from 'app/core/utils/emitter';
+import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv';
// Types
import { PanelModel } from '../state/PanelModel';
-import { DataQuery, DataSourceApi } from '@grafana/ui';
+import { DataQuery, DataSourceApi, TimeRange } from '@grafana/ui';
interface Props {
panel: PanelModel;
@@ -43,8 +44,15 @@ export class QueryEditorRow extends PureComponent {
componentDidMount() {
this.loadDatasource();
+ this.props.panel.events.on('refresh', this.onPanelRefresh);
}
+ onPanelRefresh = () => {
+ if (this.state.angularScope) {
+ this.state.angularScope.range = getTimeSrv().timeRange();
+ }
+ };
+
getAngularQueryComponentScope(): AngularQueryComponentScope {
const { panel, query } = this.props;
const { datasource } = this.state;
@@ -56,6 +64,7 @@ export class QueryEditorRow extends PureComponent {
refresh: () => panel.refresh(),
render: () => panel.render(),
events: panel.events,
+ range: getTimeSrv().timeRange(),
};
}
@@ -97,6 +106,8 @@ export class QueryEditorRow extends PureComponent {
}
componentWillUnmount() {
+ this.props.panel.events.off('refresh', this.onPanelRefresh);
+
if (this.angularQueryEditor) {
this.angularQueryEditor.destroy();
}
@@ -250,4 +261,5 @@ export interface AngularQueryComponentScope {
datasource: DataSourceApi;
toggleEditorMode?: () => void;
getCollapsedText?: () => string;
+ range: TimeRange;
}
diff --git a/public/app/features/dashboard/panel_editor/VisualizationTab.tsx b/public/app/features/dashboard/panel_editor/VisualizationTab.tsx
index 35b9b71112a..0aeb8af41d9 100644
--- a/public/app/features/dashboard/panel_editor/VisualizationTab.tsx
+++ b/public/app/features/dashboard/panel_editor/VisualizationTab.tsx
@@ -3,6 +3,9 @@ import React, { PureComponent } from 'react';
// Utils & Services
import { AngularComponent, getAngularLoader } from 'app/core/services/AngularLoader';
+import { connectWithStore } from 'app/core/utils/connectWithReduxStore';
+import { StoreState } from 'app/types';
+import { updateLocation } from 'app/core/actions';
// Components
import { EditorTabBody, EditorToolbarView } from './EditorTabBody';
@@ -21,6 +24,8 @@ interface Props {
plugin: PanelPlugin;
angularPanel?: AngularComponent;
onTypeChanged: (newType: PanelPlugin) => void;
+ updateLocation: typeof updateLocation;
+ urlOpenVizPicker: boolean;
}
interface State {
@@ -38,7 +43,7 @@ export class VisualizationTab extends PureComponent {
super(props);
this.state = {
- isVizPickerOpen: false,
+ isVizPickerOpen: this.props.urlOpenVizPicker,
searchQuery: '',
scrollTop: 0,
};
@@ -114,7 +119,12 @@ export class VisualizationTab extends PureComponent {
template +=
`
` +
- (i > 0 ? `` : '') +
+ (i > 0
+ ? ``
+ : '') +
`
@@ -149,6 +159,10 @@ export class VisualizationTab extends PureComponent
{
};
onCloseVizPicker = () => {
+ if (this.props.urlOpenVizPicker) {
+ this.props.updateLocation({ query: { openVizPicker: null }, partial: true });
+ }
+
this.setState({ isVizPickerOpen: false });
};
@@ -219,8 +233,13 @@ export class VisualizationTab extends PureComponent {
};
return (
-
+
<>
{
);
}
}
+
+const mapStateToProps = (state: StoreState) => ({
+ urlOpenVizPicker: !!state.location.query.openVizPicker,
+});
+
+const mapDispatchToProps = {
+ updateLocation,
+};
+
+export default connectWithStore(VisualizationTab, mapStateToProps, mapDispatchToProps);
diff --git a/public/app/features/dashboard/services/DashboardSrv.ts b/public/app/features/dashboard/services/DashboardSrv.ts
index 03aeb34ed36..eccb1df9e43 100644
--- a/public/app/features/dashboard/services/DashboardSrv.ts
+++ b/public/app/features/dashboard/services/DashboardSrv.ts
@@ -1,25 +1,74 @@
import coreModule from 'app/core/core_module';
-import { DashboardModel } from '../state/DashboardModel';
+import { appEvents } from 'app/core/app_events';
import locationUtil from 'app/core/utils/location_util';
+import { DashboardModel } from '../state/DashboardModel';
+import { removePanel } from '../utils/panel';
export class DashboardSrv {
- dash: any;
+ dashboard: DashboardModel;
/** @ngInject */
- constructor(private backendSrv, private $rootScope, private $location) {}
+ constructor(private backendSrv, private $rootScope, private $location) {
+ appEvents.on('save-dashboard', this.saveDashboard.bind(this), $rootScope);
+ appEvents.on('panel-change-view', this.onPanelChangeView);
+ appEvents.on('remove-panel', this.onRemovePanel);
+ }
create(dashboard, meta) {
return new DashboardModel(dashboard, meta);
}
- setCurrent(dashboard) {
- this.dash = dashboard;
+ setCurrent(dashboard: DashboardModel) {
+ this.dashboard = dashboard;
}
- getCurrent() {
- return this.dash;
+ getCurrent(): DashboardModel {
+ return this.dashboard;
}
+ onRemovePanel = (panelId: number) => {
+ const dashboard = this.getCurrent();
+ removePanel(dashboard, dashboard.getPanelById(panelId), true);
+ };
+
+ onPanelChangeView = (options) => {
+ const urlParams = this.$location.search();
+
+ // handle toggle logic
+ if (options.fullscreen === urlParams.fullscreen) {
+ // I hate using these truthy converters (!!) but in this case
+ // I think it's appropriate. edit can be null/false/undefined and
+ // here i want all of those to compare the same
+ if (!!options.edit === !!urlParams.edit) {
+ delete urlParams.fullscreen;
+ delete urlParams.edit;
+ delete urlParams.panelId;
+ this.$location.search(urlParams);
+ return;
+ }
+ }
+
+ if (options.fullscreen) {
+ urlParams.fullscreen = true;
+ } else {
+ delete urlParams.fullscreen;
+ }
+
+ if (options.edit) {
+ urlParams.edit = true;
+ } else {
+ delete urlParams.edit;
+ }
+
+ if (options.panelId || options.panelId === 0) {
+ urlParams.panelId = options.panelId;
+ } else {
+ delete urlParams.panelId;
+ }
+
+ this.$location.search(urlParams);
+ };
+
handleSaveDashboardError(clone, options, err) {
options = options || {};
options.overwrite = true;
@@ -75,10 +124,10 @@ export class DashboardSrv {
}
postSave(clone, data) {
- this.dash.version = data.version;
+ this.dashboard.version = data.version;
// important that these happens before location redirect below
- this.$rootScope.appEvent('dashboard-saved', this.dash);
+ this.$rootScope.appEvent('dashboard-saved', this.dashboard);
this.$rootScope.appEvent('alert-success', ['Dashboard saved']);
const newUrl = locationUtil.stripBaseFromUrl(data.url);
@@ -88,12 +137,12 @@ export class DashboardSrv {
this.$location.url(newUrl).replace();
}
- return this.dash;
+ return this.dashboard;
}
save(clone, options) {
options = options || {};
- options.folderId = options.folderId >= 0 ? options.folderId : this.dash.meta.folderId || clone.folderId;
+ options.folderId = options.folderId >= 0 ? options.folderId : this.dashboard.meta.folderId || clone.folderId;
return this.backendSrv
.saveDashboard(clone, options)
@@ -103,26 +152,26 @@ export class DashboardSrv {
saveDashboard(options?, clone?) {
if (clone) {
- this.setCurrent(this.create(clone, this.dash.meta));
+ this.setCurrent(this.create(clone, this.dashboard.meta));
}
- if (this.dash.meta.provisioned) {
+ if (this.dashboard.meta.provisioned) {
return this.showDashboardProvisionedModal();
}
- if (!this.dash.meta.canSave && options.makeEditable !== true) {
+ if (!this.dashboard.meta.canSave && options.makeEditable !== true) {
return Promise.resolve();
}
- if (this.dash.title === 'New dashboard') {
+ if (this.dashboard.title === 'New dashboard') {
return this.showSaveAsModal();
}
- if (this.dash.version > 0) {
+ if (this.dashboard.version > 0) {
return this.showSaveModal();
}
- return this.save(this.dash.getSaveModelClone(), options);
+ return this.save(this.dashboard.getSaveModelClone(), options);
}
saveJSONDashboard(json: string) {
@@ -163,8 +212,8 @@ export class DashboardSrv {
}
return promise.then(res => {
- if (this.dash && this.dash.id === dashboardId) {
- this.dash.meta.isStarred = res;
+ if (this.dashboard && this.dashboard.id === dashboardId) {
+ this.dashboard.meta.isStarred = res;
}
return res;
});
diff --git a/public/app/features/dashboard/services/DashboardViewStateSrv.test.ts b/public/app/features/dashboard/services/DashboardViewStateSrv.test.ts
deleted file mode 100644
index 12bb11b7a08..00000000000
--- a/public/app/features/dashboard/services/DashboardViewStateSrv.test.ts
+++ /dev/null
@@ -1,64 +0,0 @@
-import config from 'app/core/config';
-import { DashboardViewStateSrv } from './DashboardViewStateSrv';
-import { DashboardModel } from '../state/DashboardModel';
-
-describe('when updating view state', () => {
- const location = {
- replace: jest.fn(),
- search: jest.fn(),
- };
-
- const $scope = {
- appEvent: jest.fn(),
- onAppEvent: jest.fn(() => {}),
- dashboard: new DashboardModel({
- panels: [{ id: 1 }],
- }),
- };
-
- let viewState;
-
- beforeEach(() => {
- config.bootData = {
- user: {
- orgId: 1,
- },
- };
- });
-
- describe('to fullscreen true and edit true', () => {
- beforeEach(() => {
- location.search = jest.fn(() => {
- return { fullscreen: true, edit: true, panelId: 1 };
- });
- viewState = new DashboardViewStateSrv($scope, location, {});
- });
-
- it('should update querystring and view state', () => {
- const updateState = { fullscreen: true, edit: true, panelId: 1 };
-
- viewState.update(updateState);
-
- expect(location.search).toHaveBeenCalledWith({
- edit: true,
- editview: null,
- fullscreen: true,
- orgId: 1,
- panelId: 1,
- });
- expect(viewState.dashboard.meta.fullscreen).toBe(true);
- expect(viewState.state.fullscreen).toBe(true);
- });
- });
-
- describe('to fullscreen false', () => {
- beforeEach(() => {
- viewState = new DashboardViewStateSrv($scope, location, {});
- });
- it('should remove params from query string', () => {
- viewState.update({ fullscreen: true, panelId: 1, edit: true });
- viewState.update({ fullscreen: false });
- expect(viewState.state.fullscreen).toBe(null);
- });
- });
-});
diff --git a/public/app/features/dashboard/services/DashboardViewStateSrv.ts b/public/app/features/dashboard/services/DashboardViewStateSrv.ts
deleted file mode 100644
index fc38c3b241f..00000000000
--- a/public/app/features/dashboard/services/DashboardViewStateSrv.ts
+++ /dev/null
@@ -1,185 +0,0 @@
-import angular from 'angular';
-import _ from 'lodash';
-import config from 'app/core/config';
-import appEvents from 'app/core/app_events';
-import { DashboardModel } from '../state/DashboardModel';
-
-// represents the transient view state
-// like fullscreen panel & edit
-export class DashboardViewStateSrv {
- state: any;
- panelScopes: any;
- $scope: any;
- dashboard: DashboardModel;
- fullscreenPanel: any;
- oldTimeRange: any;
-
- /** @ngInject */
- constructor($scope, private $location, private $timeout) {
- const self = this;
- self.state = {};
- self.panelScopes = [];
- self.$scope = $scope;
- self.dashboard = $scope.dashboard;
-
- $scope.onAppEvent('$routeUpdate', () => {
- const urlState = self.getQueryStringState();
- if (self.needsSync(urlState)) {
- self.update(urlState, true);
- }
- });
-
- $scope.onAppEvent('panel-change-view', (evt, payload) => {
- self.update(payload);
- });
-
- // this marks changes to location during this digest cycle as not to add history item
- // don't want url changes like adding orgId to add browser history
- $location.replace();
- this.update(this.getQueryStringState());
- }
-
- needsSync(urlState) {
- return _.isEqual(this.state, urlState) === false;
- }
-
- getQueryStringState() {
- const state = this.$location.search();
- state.panelId = parseInt(state.panelId, 10) || null;
- state.fullscreen = state.fullscreen ? true : null;
- state.edit = state.edit === 'true' || state.edit === true || null;
- state.editview = state.editview || null;
- state.orgId = config.bootData.user.orgId;
- return state;
- }
-
- serializeToUrl() {
- const urlState = _.clone(this.state);
- urlState.fullscreen = this.state.fullscreen ? true : null;
- urlState.edit = this.state.edit ? true : null;
- return urlState;
- }
-
- update(state, fromRouteUpdated?) {
- // implement toggle logic
- if (state.toggle) {
- delete state.toggle;
- if (this.state.fullscreen && state.fullscreen) {
- if (this.state.edit === state.edit) {
- state.fullscreen = !state.fullscreen;
- }
- }
- }
-
- _.extend(this.state, state);
-
- if (!this.state.fullscreen) {
- this.state.fullscreen = null;
- this.state.edit = null;
- // clear panel id unless in solo mode
- if (!this.dashboard.meta.soloMode) {
- this.state.panelId = null;
- }
- }
-
- if ((this.state.fullscreen || this.dashboard.meta.soloMode) && this.state.panelId) {
- // Trying to render panel in fullscreen when it's in the collapsed row causes an issue.
- // So in this case expand collapsed row first.
- this.toggleCollapsedPanelRow(this.state.panelId);
- }
-
- // if no edit state cleanup tab parm
- if (!this.state.edit) {
- delete this.state.tab;
- }
-
- // do not update url params if we are here
- // from routeUpdated event
- if (fromRouteUpdated !== true) {
- this.$location.search(this.serializeToUrl());
- }
-
- this.syncState();
- }
-
- toggleCollapsedPanelRow(panelId) {
- for (const panel of this.dashboard.panels) {
- if (panel.collapsed) {
- for (const rowPanel of panel.panels) {
- if (rowPanel.id === panelId) {
- this.dashboard.toggleRow(panel);
- return;
- }
- }
- }
- }
- }
-
- syncState() {
- if (this.state.fullscreen) {
- const panel = this.dashboard.getPanelById(this.state.panelId);
-
- if (!panel) {
- this.state.fullscreen = null;
- this.state.panelId = null;
- this.state.edit = null;
-
- this.update(this.state);
-
- setTimeout(() => {
- appEvents.emit('alert-error', ['Error', 'Panel not found']);
- }, 100);
-
- return;
- }
-
- if (!panel.fullscreen) {
- this.enterFullscreen(panel);
- } else if (this.dashboard.meta.isEditing !== this.state.edit) {
- this.dashboard.setViewMode(panel, this.state.fullscreen, this.state.edit);
- }
- } else if (this.fullscreenPanel) {
- this.leaveFullscreen();
- }
- }
-
- leaveFullscreen() {
- const panel = this.fullscreenPanel;
-
- this.dashboard.setViewMode(panel, false, false);
-
- delete this.fullscreenPanel;
-
- this.$timeout(() => {
- appEvents.emit('dash-scroll', { restore: true });
-
- if (this.oldTimeRange !== this.dashboard.time) {
- this.dashboard.startRefresh();
- } else {
- this.dashboard.render();
- }
- });
- }
-
- enterFullscreen(panel) {
- const isEditing = this.state.edit && this.dashboard.meta.canEdit;
-
- this.oldTimeRange = this.dashboard.time;
- this.fullscreenPanel = panel;
-
- // Firefox doesn't return scrollTop position properly if 'dash-scroll' is emitted after setViewMode()
- this.$scope.appEvent('dash-scroll', { animate: false, pos: 0 });
- this.dashboard.setViewMode(panel, true, isEditing);
- }
-}
-
-/** @ngInject */
-export function dashboardViewStateSrv($location, $timeout) {
- return {
- create: $scope => {
- return new DashboardViewStateSrv($scope, $location, $timeout);
- },
- };
-}
-
-angular.module('grafana.services').factory('dashboardViewStateSrv', dashboardViewStateSrv);
diff --git a/public/app/features/dashboard/state/DashboardModel.ts b/public/app/features/dashboard/state/DashboardModel.ts
index 929b984a93f..aec6421d12a 100644
--- a/public/app/features/dashboard/state/DashboardModel.ts
+++ b/public/app/features/dashboard/state/DashboardModel.ts
@@ -1,20 +1,26 @@
+// Libaries
import moment from 'moment';
import _ from 'lodash';
-import { DEFAULT_ANNOTATION_COLOR } from '@grafana/ui';
+// Constants
+import { DEFAULT_ANNOTATION_COLOR } from '@grafana/ui';
import { GRID_COLUMN_COUNT, REPEAT_DIR_VERTICAL, GRID_CELL_HEIGHT, GRID_CELL_VMARGIN } from 'app/core/constants';
+
+// Utils & Services
import { Emitter } from 'app/core/utils/emitter';
import { contextSrv } from 'app/core/services/context_srv';
import sortByKeys from 'app/core/utils/sort_by_keys';
+// Types
import { PanelModel } from './PanelModel';
import { DashboardMigrator } from './DashboardMigrator';
import { TimeRange } from '@grafana/ui/src';
+import { UrlQueryValue, KIOSK_MODE_TV, DashboardMeta } from 'app/types';
export class DashboardModel {
id: any;
- uid: any;
- title: any;
+ uid: string;
+ title: string;
autoUpdate: any;
description: any;
tags: any;
@@ -43,7 +49,7 @@ export class DashboardModel {
// repeat process cycles
iteration: number;
- meta: any;
+ meta: DashboardMeta;
events: Emitter;
static nonPersistedProperties: { [str: string]: boolean } = {
@@ -127,6 +133,8 @@ export class DashboardModel {
meta.canEdit = meta.canEdit !== false;
meta.showSettings = meta.canEdit;
meta.canMakeEditable = meta.canSave && !this.editable;
+ meta.fullscreen = false;
+ meta.isEditing = false;
if (!this.editable) {
meta.canEdit = false;
@@ -860,11 +868,7 @@ export class DashboardModel {
return !_.isEqual(updated, this.originalTemplating);
}
- autoFitPanels(viewHeight: number) {
- if (!this.meta.autofitpanels) {
- return;
- }
-
+ autoFitPanels(viewHeight: number, kioskMode?: UrlQueryValue) {
const currentGridHeight = Math.max(
...this.panels.map(panel => {
return panel.gridPos.h + panel.gridPos.y;
@@ -878,12 +882,12 @@ export class DashboardModel {
let visibleHeight = viewHeight - navbarHeight - margin;
// Remove submenu height if visible
- if (this.meta.submenuEnabled && !this.meta.kiosk) {
+ if (this.meta.submenuEnabled && !kioskMode) {
visibleHeight -= submenuHeight;
}
// add back navbar height
- if (this.meta.kiosk === 'b') {
+ if (kioskMode === KIOSK_MODE_TV) {
visibleHeight += 55;
}
@@ -895,4 +899,23 @@ export class DashboardModel {
panel.gridPos.h = Math.round(panel.gridPos.h / scaleFactor) || 1;
});
}
+
+ templateVariableValueUpdated() {
+ this.processRepeats();
+ this.events.emit('template-variable-value-updated');
+ }
+
+ expandParentRowFor(panelId: number) {
+ for (const panel of this.panels) {
+ if (panel.collapsed) {
+ for (const rowPanel of panel.panels) {
+ if (rowPanel.id === panelId) {
+ this.toggleRow(panel);
+ return;
+ }
+ }
+ }
+ }
+ }
+
}
diff --git a/public/app/features/dashboard/state/actions.ts b/public/app/features/dashboard/state/actions.ts
index 4dcf0a925b7..f5911a233f7 100644
--- a/public/app/features/dashboard/state/actions.ts
+++ b/public/app/features/dashboard/state/actions.ts
@@ -1,39 +1,43 @@
-import { StoreState } from 'app/types';
-import { ThunkAction } from 'redux-thunk';
+// Services & Utils
import { getBackendSrv } from 'app/core/services/backend_srv';
-import appEvents from 'app/core/app_events';
+import { actionCreatorFactory, noPayloadActionCreatorFactory } from 'app/core/redux';
+import { createSuccessNotification } from 'app/core/copy/appNotification';
+
+// Actions
import { loadPluginDashboards } from '../../plugins/state/actions';
+import { notifyApp } from 'app/core/actions';
+
+// Types
import {
+ ThunkResult,
DashboardAcl,
DashboardAclDTO,
PermissionLevel,
DashboardAclUpdateDTO,
NewDashboardAclItem,
-} from 'app/types/acl';
+ MutableDashboard,
+ DashboardInitError,
+} from 'app/types';
-export enum ActionTypes {
- LoadDashboardPermissions = 'LOAD_DASHBOARD_PERMISSIONS',
- LoadStarredDashboards = 'LOAD_STARRED_DASHBOARDS',
-}
+export const loadDashboardPermissions = actionCreatorFactory('LOAD_DASHBOARD_PERMISSIONS').create();
-export interface LoadDashboardPermissionsAction {
- type: ActionTypes.LoadDashboardPermissions;
- payload: DashboardAcl[];
-}
+export const dashboardInitFetching = noPayloadActionCreatorFactory('DASHBOARD_INIT_FETCHING').create();
-export interface LoadStarredDashboardsAction {
- type: ActionTypes.LoadStarredDashboards;
- payload: DashboardAcl[];
-}
+export const dashboardInitServices = noPayloadActionCreatorFactory('DASHBOARD_INIT_SERVICES').create();
-export type Action = LoadDashboardPermissionsAction | LoadStarredDashboardsAction;
+export const dashboardInitSlow = noPayloadActionCreatorFactory('SET_DASHBOARD_INIT_SLOW').create();
-type ThunkResult = ThunkAction;
+export const dashboardInitCompleted = actionCreatorFactory('DASHBOARD_INIT_COMLETED').create();
-export const loadDashboardPermissions = (items: DashboardAclDTO[]): LoadDashboardPermissionsAction => ({
- type: ActionTypes.LoadDashboardPermissions,
- payload: items,
-});
+/*
+ * Unrecoverable init failure (fetch or model creation failed)
+ */
+export const dashboardInitFailed = actionCreatorFactory('DASHBOARD_INIT_FAILED').create();
+
+/*
+ * When leaving dashboard, resets state
+ * */
+export const cleanUpDashboard = noPayloadActionCreatorFactory('DASHBOARD_CLEAN_UP').create();
export function getDashboardPermissions(id: number): ThunkResult {
return async dispatch => {
@@ -124,7 +128,7 @@ export function addDashboardPermission(dashboardId: number, newItem: NewDashboar
export function importDashboard(data, dashboardTitle: string): ThunkResult {
return async dispatch => {
await getBackendSrv().post('/api/dashboards/import', data);
- appEvents.emit('alert-success', ['Dashboard Imported', dashboardTitle]);
+ dispatch(notifyApp(createSuccessNotification('Dashboard Imported', dashboardTitle)));
dispatch(loadPluginDashboards());
};
}
@@ -135,3 +139,4 @@ export function removeDashboard(uri: string): ThunkResult {
dispatch(loadPluginDashboards());
};
}
+
diff --git a/public/app/features/dashboard/state/initDashboard.test.ts b/public/app/features/dashboard/state/initDashboard.test.ts
new file mode 100644
index 00000000000..ebef6fd8494
--- /dev/null
+++ b/public/app/features/dashboard/state/initDashboard.test.ts
@@ -0,0 +1,152 @@
+import configureMockStore from 'redux-mock-store';
+import thunk from 'redux-thunk';
+import { initDashboard, InitDashboardArgs } from './initDashboard';
+import { DashboardRouteInfo } from 'app/types';
+import { getBackendSrv } from 'app/core/services/backend_srv';
+import {
+ dashboardInitFetching,
+ dashboardInitCompleted,
+ dashboardInitServices,
+} from './actions';
+
+jest.mock('app/core/services/backend_srv');
+
+const mockStore = configureMockStore([thunk]);
+
+interface ScenarioContext {
+ args: InitDashboardArgs;
+ timeSrv: any;
+ annotationsSrv: any;
+ unsavedChangesSrv: any;
+ variableSrv: any;
+ dashboardSrv: any;
+ keybindingSrv: any;
+ backendSrv: any;
+ setup: (fn: () => void) => void;
+ actions: any[];
+ storeState: any;
+}
+
+type ScenarioFn = (ctx: ScenarioContext) => void;
+
+function describeInitScenario(description: string, scenarioFn: ScenarioFn) {
+ describe(description, () => {
+ const timeSrv = { init: jest.fn() };
+ const annotationsSrv = { init: jest.fn() };
+ const unsavedChangesSrv = { init: jest.fn() };
+ const variableSrv = { init: jest.fn() };
+ const dashboardSrv = { setCurrent: jest.fn() };
+ const keybindingSrv = { setupDashboardBindings: jest.fn() };
+
+ const injectorMock = {
+ get: (name: string) => {
+ switch (name) {
+ case 'timeSrv':
+ return timeSrv;
+ case 'annotationsSrv':
+ return annotationsSrv;
+ case 'unsavedChangesSrv':
+ return unsavedChangesSrv;
+ case 'dashboardSrv':
+ return dashboardSrv;
+ case 'variableSrv':
+ return variableSrv;
+ case 'keybindingSrv':
+ return keybindingSrv;
+ default:
+ throw { message: 'Unknown service ' + name };
+ }
+ },
+ };
+
+ let setupFn = () => {};
+
+ const ctx: ScenarioContext = {
+ args: {
+ $injector: injectorMock,
+ $scope: {},
+ fixUrl: false,
+ routeInfo: DashboardRouteInfo.Normal,
+ },
+ backendSrv: getBackendSrv(),
+ timeSrv,
+ annotationsSrv,
+ unsavedChangesSrv,
+ variableSrv,
+ dashboardSrv,
+ keybindingSrv,
+ actions: [],
+ storeState: {
+ location: {
+ query: {},
+ },
+ user: {},
+ },
+ setup: (fn: () => void) => {
+ setupFn = fn;
+ },
+ };
+
+ beforeEach(async () => {
+ setupFn();
+
+ const store = mockStore(ctx.storeState);
+
+ await store.dispatch(initDashboard(ctx.args));
+
+ ctx.actions = store.getActions();
+ });
+
+ scenarioFn(ctx);
+ });
+}
+
+describeInitScenario('Initializing new dashboard', ctx => {
+ ctx.setup(() => {
+ ctx.storeState.user.orgId = 12;
+ ctx.args.routeInfo = DashboardRouteInfo.New;
+ });
+
+ it('Should send action dashboardInitFetching', () => {
+ expect(ctx.actions[0].type).toBe(dashboardInitFetching.type);
+ });
+
+ it('Should send action dashboardInitServices ', () => {
+ expect(ctx.actions[1].type).toBe(dashboardInitServices.type);
+ });
+
+ it('Should update location with orgId query param', () => {
+ expect(ctx.actions[2].type).toBe('UPDATE_LOCATION');
+ expect(ctx.actions[2].payload.query.orgId).toBe(12);
+ });
+
+ it('Should send action dashboardInitCompleted', () => {
+ expect(ctx.actions[3].type).toBe(dashboardInitCompleted.type);
+ expect(ctx.actions[3].payload.title).toBe('New dashboard');
+ });
+
+ it('Should Initializing services', () => {
+ expect(ctx.timeSrv.init).toBeCalled();
+ expect(ctx.annotationsSrv.init).toBeCalled();
+ expect(ctx.variableSrv.init).toBeCalled();
+ expect(ctx.unsavedChangesSrv.init).toBeCalled();
+ expect(ctx.keybindingSrv.setupDashboardBindings).toBeCalled();
+ expect(ctx.dashboardSrv.setCurrent).toBeCalled();
+ });
+});
+
+describeInitScenario('Initializing home dashboard', ctx => {
+ ctx.setup(() => {
+ ctx.args.routeInfo = DashboardRouteInfo.Home;
+ ctx.backendSrv.get.mockReturnValue(Promise.resolve({
+ redirectUri: '/u/123/my-home'
+ }));
+ });
+
+ it('Should redirect to custom home dashboard', () => {
+ expect(ctx.actions[1].type).toBe('UPDATE_LOCATION');
+ expect(ctx.actions[1].payload.path).toBe('/u/123/my-home');
+ });
+});
+
+
diff --git a/public/app/features/dashboard/state/initDashboard.ts b/public/app/features/dashboard/state/initDashboard.ts
new file mode 100644
index 00000000000..e6f83780430
--- /dev/null
+++ b/public/app/features/dashboard/state/initDashboard.ts
@@ -0,0 +1,233 @@
+// Services & Utils
+import { createErrorNotification } from 'app/core/copy/appNotification';
+import { getBackendSrv } from 'app/core/services/backend_srv';
+import { DashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
+import { DashboardLoaderSrv } from 'app/features/dashboard/services/DashboardLoaderSrv';
+import { TimeSrv } from 'app/features/dashboard/services/TimeSrv';
+import { AnnotationsSrv } from 'app/features/annotations/annotations_srv';
+import { VariableSrv } from 'app/features/templating/variable_srv';
+import { KeybindingSrv } from 'app/core/services/keybindingSrv';
+
+// Actions
+import { updateLocation } from 'app/core/actions';
+import { notifyApp } from 'app/core/actions';
+import locationUtil from 'app/core/utils/location_util';
+import {
+ dashboardInitFetching,
+ dashboardInitCompleted,
+ dashboardInitFailed,
+ dashboardInitSlow,
+ dashboardInitServices,
+} from './actions';
+
+// Types
+import { DashboardRouteInfo, StoreState, ThunkDispatch, ThunkResult, DashboardDTO } from 'app/types';
+import { DashboardModel } from './DashboardModel';
+
+export interface InitDashboardArgs {
+ $injector: any;
+ $scope: any;
+ urlUid?: string;
+ urlSlug?: string;
+ urlType?: string;
+ urlFolderId?: string;
+ routeInfo: DashboardRouteInfo;
+ fixUrl: boolean;
+}
+
+async function redirectToNewUrl(slug: string, dispatch: ThunkDispatch, currentPath: string) {
+ const res = await getBackendSrv().getDashboardBySlug(slug);
+
+ if (res) {
+ let newUrl = res.meta.url;
+
+ // fix solo route urls
+ if (currentPath.indexOf('dashboard-solo') !== -1) {
+ newUrl = newUrl.replace('/d/', '/d-solo/');
+ }
+
+ const url = locationUtil.stripBaseFromUrl(newUrl);
+ dispatch(updateLocation({ path: url, partial: true, replace: true }));
+ }
+}
+
+async function fetchDashboard(
+ args: InitDashboardArgs,
+ dispatch: ThunkDispatch,
+ getState: () => StoreState
+): Promise {
+ try {
+ switch (args.routeInfo) {
+ case DashboardRouteInfo.Home: {
+ // load home dash
+ const dashDTO: DashboardDTO = await getBackendSrv().get('/api/dashboards/home');
+
+ // if user specified a custom home dashboard redirect to that
+ if (dashDTO.redirectUri) {
+ const newUrl = locationUtil.stripBaseFromUrl(dashDTO.redirectUri);
+ dispatch(updateLocation({ path: newUrl, replace: true }));
+ return null;
+ }
+
+ // disable some actions on the default home dashboard
+ dashDTO.meta.canSave = false;
+ dashDTO.meta.canShare = false;
+ dashDTO.meta.canStar = false;
+ return dashDTO;
+ }
+ case DashboardRouteInfo.Normal: {
+ // for old db routes we redirect
+ if (args.urlType === 'db') {
+ redirectToNewUrl(args.urlSlug, dispatch, getState().location.path);
+ return null;
+ }
+
+ const loaderSrv: DashboardLoaderSrv = args.$injector.get('dashboardLoaderSrv');
+ const dashDTO: DashboardDTO = await loaderSrv.loadDashboard(args.urlType, args.urlSlug, args.urlUid);
+
+ if (args.fixUrl && dashDTO.meta.url) {
+ // check if the current url is correct (might be old slug)
+ const dashboardUrl = locationUtil.stripBaseFromUrl(dashDTO.meta.url);
+ const currentPath = getState().location.path;
+
+ if (dashboardUrl !== currentPath) {
+ // replace url to not create additional history items and then return so that initDashboard below isn't executed multiple times.
+ dispatch(updateLocation({ path: dashboardUrl, partial: true, replace: true }));
+ return null;
+ }
+ }
+ return dashDTO;
+ }
+ case DashboardRouteInfo.New: {
+ return getNewDashboardModelData(args.urlFolderId);
+ }
+ default:
+ throw { message: 'Unknown route ' + args.routeInfo };
+ }
+ } catch (err) {
+ dispatch(dashboardInitFailed({ message: 'Failed to fetch dashboard', error: err }));
+ console.log(err);
+ return null;
+ }
+}
+
+/**
+ * This action (or saga) does everything needed to bootstrap a dashboard & dashboard model.
+ * First it handles the process of fetching the dashboard, correcting the url if required (causing redirects/url updates)
+ *
+ * This is used both for single dashboard & solo panel routes, home & new dashboard routes.
+ *
+ * Then it handles the initializing of the old angular services that the dashboard components & panels still depend on
+ *
+ */
+export function initDashboard(args: InitDashboardArgs): ThunkResult {
+ return async (dispatch, getState) => {
+ // set fetching state
+ dispatch(dashboardInitFetching());
+
+ // Detect slow loading / initializing and set state flag
+ // This is in order to not show loading indication for fast loading dashboards as it creates blinking/flashing
+ setTimeout(() => {
+ if (getState().dashboard.model === null) {
+ dispatch(dashboardInitSlow());
+ }
+ }, 500);
+
+ // fetch dashboard data
+ const dashDTO = await fetchDashboard(args, dispatch, getState);
+
+ // returns null if there was a redirect or error
+ if (!dashDTO) {
+ return;
+ }
+
+ // set initializing state
+ dispatch(dashboardInitServices());
+
+ // create model
+ let dashboard: DashboardModel;
+ try {
+ dashboard = new DashboardModel(dashDTO.dashboard, dashDTO.meta);
+ } catch (err) {
+ dispatch(dashboardInitFailed({ message: 'Failed create dashboard model', error: err }));
+ console.log(err);
+ return;
+ }
+
+ // add missing orgId query param
+ const storeState = getState();
+ if (!storeState.location.query.orgId) {
+ dispatch(updateLocation({ query: { orgId: storeState.user.orgId }, partial: true, replace: true }));
+ }
+
+ // init services
+ const timeSrv: TimeSrv = args.$injector.get('timeSrv');
+ const annotationsSrv: AnnotationsSrv = args.$injector.get('annotationsSrv');
+ const variableSrv: VariableSrv = args.$injector.get('variableSrv');
+ const keybindingSrv: KeybindingSrv = args.$injector.get('keybindingSrv');
+ const unsavedChangesSrv = args.$injector.get('unsavedChangesSrv');
+ const dashboardSrv: DashboardSrv = args.$injector.get('dashboardSrv');
+
+ timeSrv.init(dashboard);
+ annotationsSrv.init(dashboard);
+
+ // template values service needs to initialize completely before
+ // the rest of the dashboard can load
+ try {
+ await variableSrv.init(dashboard);
+ } catch (err) {
+ dispatch(notifyApp(createErrorNotification('Templating init failed', err)));
+ console.log(err);
+ }
+
+ try {
+ dashboard.processRepeats();
+ dashboard.updateSubmenuVisibility();
+
+ // handle auto fix experimental feature
+ const queryParams = getState().location.query;
+ if (queryParams.autofitpanels) {
+ dashboard.autoFitPanels(window.innerHeight, queryParams.kiosk);
+ }
+
+ // init unsaved changes tracking
+ unsavedChangesSrv.init(dashboard, args.$scope);
+ keybindingSrv.setupDashboardBindings(args.$scope, dashboard);
+ } catch (err) {
+ dispatch(notifyApp(createErrorNotification('Dashboard init failed', err)));
+ console.log(err);
+ }
+
+ // legacy srv state
+ dashboardSrv.setCurrent(dashboard);
+ // yay we are done
+ dispatch(dashboardInitCompleted(dashboard));
+ };
+}
+
+function getNewDashboardModelData(urlFolderId?: string): any {
+ const data = {
+ meta: {
+ canStar: false,
+ canShare: false,
+ isNew: true,
+ folderId: 0,
+ },
+ dashboard: {
+ title: 'New dashboard',
+ panels: [
+ {
+ type: 'add-panel',
+ gridPos: { x: 0, y: 0, w: 12, h: 9 },
+ title: 'Panel Title',
+ },
+ ],
+ },
+ };
+
+ if (urlFolderId) {
+ data.meta.folderId = parseInt(urlFolderId, 10);
+ }
+
+ return data;
+}
diff --git a/public/app/features/dashboard/state/reducers.test.ts b/public/app/features/dashboard/state/reducers.test.ts
index ced8866aad8..800ba27e800 100644
--- a/public/app/features/dashboard/state/reducers.test.ts
+++ b/public/app/features/dashboard/state/reducers.test.ts
@@ -1,19 +1,23 @@
-import { Action, ActionTypes } from './actions';
-import { OrgRole, PermissionLevel, DashboardState } from 'app/types';
+import {
+ loadDashboardPermissions,
+ dashboardInitFetching,
+ dashboardInitCompleted,
+ dashboardInitFailed,
+ dashboardInitSlow,
+} from './actions';
+import { OrgRole, PermissionLevel, DashboardState, DashboardInitPhase } from 'app/types';
import { initialState, dashboardReducer } from './reducers';
+import { DashboardModel } from './DashboardModel';
describe('dashboard reducer', () => {
describe('loadDashboardPermissions', () => {
let state: DashboardState;
beforeEach(() => {
- const action: Action = {
- type: ActionTypes.LoadDashboardPermissions,
- payload: [
- { id: 2, dashboardId: 1, role: OrgRole.Viewer, permission: PermissionLevel.View },
- { id: 3, dashboardId: 1, role: OrgRole.Editor, permission: PermissionLevel.Edit },
- ],
- };
+ const action = loadDashboardPermissions([
+ { id: 2, dashboardId: 1, role: OrgRole.Viewer, permission: PermissionLevel.View },
+ { id: 3, dashboardId: 1, role: OrgRole.Editor, permission: PermissionLevel.Edit },
+ ]);
state = dashboardReducer(initialState, action);
});
@@ -21,4 +25,47 @@ describe('dashboard reducer', () => {
expect(state.permissions.length).toBe(2);
});
});
+
+ describe('dashboardInitCompleted', () => {
+ let state: DashboardState;
+
+ beforeEach(() => {
+ state = dashboardReducer(initialState, dashboardInitFetching());
+ state = dashboardReducer(state, dashboardInitSlow());
+ state = dashboardReducer(state, dashboardInitCompleted(new DashboardModel({ title: 'My dashboard' })));
+ });
+
+ it('should set model', async () => {
+ expect(state.model.title).toBe('My dashboard');
+ });
+
+ it('should set reset isInitSlow', async () => {
+ expect(state.isInitSlow).toBe(false);
+ });
+ });
+
+ describe('dashboardInitFailed', () => {
+ let state: DashboardState;
+
+ beforeEach(() => {
+ state = dashboardReducer(initialState, dashboardInitFetching());
+ state = dashboardReducer(state, dashboardInitFailed({message: 'Oh no', error: 'sad'}));
+ });
+
+ it('should set model', async () => {
+ expect(state.model.title).toBe('Dashboard init failed');
+ });
+
+ it('should set reset isInitSlow', async () => {
+ expect(state.isInitSlow).toBe(false);
+ });
+
+ it('should set initError', async () => {
+ expect(state.initError.message).toBe('Oh no');
+ });
+
+ it('should set phase failed', async () => {
+ expect(state.initPhase).toBe(DashboardInitPhase.Failed);
+ });
+ });
});
diff --git a/public/app/features/dashboard/state/reducers.ts b/public/app/features/dashboard/state/reducers.ts
index 8a79a6c9f77..7a320beee93 100644
--- a/public/app/features/dashboard/state/reducers.ts
+++ b/public/app/features/dashboard/state/reducers.ts
@@ -1,21 +1,90 @@
-import { DashboardState } from 'app/types';
-import { Action, ActionTypes } from './actions';
+import { DashboardState, DashboardInitPhase } from 'app/types';
+import {
+ loadDashboardPermissions,
+ dashboardInitFetching,
+ dashboardInitSlow,
+ dashboardInitServices,
+ dashboardInitFailed,
+ dashboardInitCompleted,
+ cleanUpDashboard,
+} from './actions';
+import { reducerFactory } from 'app/core/redux';
import { processAclItems } from 'app/core/utils/acl';
+import { DashboardModel } from './DashboardModel';
export const initialState: DashboardState = {
+ initPhase: DashboardInitPhase.NotStarted,
+ isInitSlow: false,
+ model: null,
permissions: [],
};
-export const dashboardReducer = (state = initialState, action: Action): DashboardState => {
- switch (action.type) {
- case ActionTypes.LoadDashboardPermissions:
+export const dashboardReducer = reducerFactory(initialState)
+ .addMapper({
+ filter: loadDashboardPermissions,
+ mapper: (state, action) => ({
+ ...state,
+ permissions: processAclItems(action.payload),
+ }),
+ })
+ .addMapper({
+ filter: dashboardInitFetching,
+ mapper: state => ({
+ ...state,
+ initPhase: DashboardInitPhase.Fetching,
+ }),
+ })
+ .addMapper({
+ filter: dashboardInitServices,
+ mapper: state => ({
+ ...state,
+ initPhase: DashboardInitPhase.Services,
+ }),
+ })
+ .addMapper({
+ filter: dashboardInitSlow,
+ mapper: state => ({
+ ...state,
+ isInitSlow: true,
+ }),
+ })
+ .addMapper({
+ filter: dashboardInitFailed,
+ mapper: (state, action) => ({
+ ...state,
+ initPhase: DashboardInitPhase.Failed,
+ isInitSlow: false,
+ initError: action.payload,
+ model: new DashboardModel({ title: 'Dashboard init failed' }, { canSave: false, canEdit: false }),
+ }),
+ })
+ .addMapper({
+ filter: dashboardInitCompleted,
+ mapper: (state, action) => ({
+ ...state,
+ initPhase: DashboardInitPhase.Completed,
+ model: action.payload,
+ isInitSlow: false,
+ }),
+ })
+ .addMapper({
+ filter: cleanUpDashboard,
+ mapper: (state, action) => {
+
+ // Destroy current DashboardModel
+ // Very important as this removes all dashboard event listeners
+ state.model.destroy();
+
return {
...state,
- permissions: processAclItems(action.payload),
+ initPhase: DashboardInitPhase.NotStarted,
+ model: null,
+ isInitSlow: false,
+ initError: null,
};
- }
- return state;
-};
+ },
+ })
+ .create();
export default {
dashboard: dashboardReducer,
diff --git a/public/app/features/datasources/partials/http_settings.html b/public/app/features/datasources/partials/http_settings.html
index 521e2d3cdc6..b6f2c4fc0dd 100644
--- a/public/app/features/datasources/partials/http_settings.html
+++ b/public/app/features/datasources/partials/http_settings.html
@@ -101,53 +101,5 @@
-
-
+
+
\ No newline at end of file
diff --git a/public/app/features/datasources/partials/tls_auth_settings.html b/public/app/features/datasources/partials/tls_auth_settings.html
new file mode 100644
index 00000000000..c852e8ec70c
--- /dev/null
+++ b/public/app/features/datasources/partials/tls_auth_settings.html
@@ -0,0 +1,62 @@
+
diff --git a/public/app/features/datasources/settings/TlsAuthSettingsCtrl.ts b/public/app/features/datasources/settings/TlsAuthSettingsCtrl.ts
new file mode 100644
index 00000000000..7c21fab404c
--- /dev/null
+++ b/public/app/features/datasources/settings/TlsAuthSettingsCtrl.ts
@@ -0,0 +1,10 @@
+import { coreModule } from 'app/core/core';
+
+coreModule.directive('datasourceTlsAuthSettings', () => {
+ return {
+ scope: {
+ current: '=',
+ },
+ templateUrl: 'public/app/features/datasources/partials/tls_auth_settings.html',
+ };
+});
diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx
index b210bcccc18..a28776d813a 100644
--- a/public/app/features/explore/Explore.tsx
+++ b/public/app/features/explore/Explore.tsx
@@ -205,28 +205,34 @@ export class Explore extends React.PureComponent {
- {({ width }) => (
-
-
- {showingStartPage && }
- {!showingStartPage && (
- <>
- {supportsGraph && !supportsLogs && }
- {supportsTable && }
- {supportsLogs && (
-
- )}
- >
- )}
-
-
- )}
+ {({ width }) => {
+ if (width === 0) {
+ return null;
+ }
+
+ return (
+
+
+ {showingStartPage && }
+ {!showingStartPage && (
+ <>
+ {supportsGraph && !supportsLogs && }
+ {supportsTable && }
+ {supportsLogs && (
+
+ )}
+ >
+ )}
+
+
+ );
+ }}
)}
diff --git a/public/app/features/explore/ExploreToolbar.tsx b/public/app/features/explore/ExploreToolbar.tsx
index 35f06d11c81..786998c96c1 100644
--- a/public/app/features/explore/ExploreToolbar.tsx
+++ b/public/app/features/explore/ExploreToolbar.tsx
@@ -8,6 +8,7 @@ import { DataSourcePicker } from 'app/core/components/Select/DataSourcePicker';
import { StoreState } from 'app/types/store';
import { changeDatasource, clearQueries, splitClose, runQueries, splitOpen } from './state/actions';
import TimePicker from './TimePicker';
+import { ClickOutsideWrapper } from 'app/core/components/ClickOutsideWrapper/ClickOutsideWrapper';
enum IconSide {
left = 'left',
@@ -79,6 +80,10 @@ export class UnConnectedExploreToolbar extends PureComponent {
this.props.runQuery(this.props.exploreId);
};
+ onCloseTimePicker = () => {
+ this.props.timepickerRef.current.setState({ isOpen: false });
+ };
+
render() {
const {
datasourceMissing,
@@ -97,10 +102,10 @@ export class UnConnectedExploreToolbar extends PureComponent {
@@ -137,7 +142,9 @@ export class UnConnectedExploreToolbar extends PureComponent
{
) : null}
-
+
+
+
diff --git a/public/app/features/explore/Graph.test.tsx b/public/app/features/explore/Graph.test.tsx
index fe4deaf17aa..8976c677592 100644
--- a/public/app/features/explore/Graph.test.tsx
+++ b/public/app/features/explore/Graph.test.tsx
@@ -5,6 +5,7 @@ import { mockData } from './__mocks__/mockData';
const setup = (propOverrides?: object) => {
const props = {
+ size: { width: 10, height: 20 },
data: mockData().slice(0, 19),
range: { from: 'now-6h', to: 'now' },
...propOverrides,
diff --git a/public/app/features/explore/Graph.tsx b/public/app/features/explore/Graph.tsx
index 5d64dde28ce..3a641697781 100644
--- a/public/app/features/explore/Graph.tsx
+++ b/public/app/features/explore/Graph.tsx
@@ -1,7 +1,6 @@
import $ from 'jquery';
import React, { PureComponent } from 'react';
import moment from 'moment';
-import { withSize } from 'react-sizeme';
import 'vendor/flot/jquery.flot';
import 'vendor/flot/jquery.flot.time';
@@ -76,11 +75,11 @@ const FLOT_OPTIONS = {
interface GraphProps {
data: any[];
- height?: string; // e.g., '200px'
+ height?: number;
+ width?: number;
id?: string;
range: RawTimeRange;
split?: boolean;
- size?: { width: number; height: number };
userOptions?: any;
onChangeTime?: (range: RawTimeRange) => void;
onToggleSeries?: (alias: string, hiddenSeries: Set) => void;
@@ -122,7 +121,7 @@ export class Graph extends PureComponent {
prevProps.range !== this.props.range ||
prevProps.split !== this.props.split ||
prevProps.height !== this.props.height ||
- (prevProps.size && prevProps.size.width !== this.props.size.width) ||
+ prevProps.width !== this.props.width ||
!equal(prevState.hiddenSeries, this.state.hiddenSeries)
) {
this.draw();
@@ -144,8 +143,8 @@ export class Graph extends PureComponent {
};
getDynamicOptions() {
- const { range, size } = this.props;
- const ticks = (size.width || 0) / 100;
+ const { range, width } = this.props;
+ const ticks = (width || 0) / 100;
let { from, to } = range;
if (!moment.isMoment(from)) {
from = dateMath.parse(from, false);
@@ -237,7 +236,7 @@ export class Graph extends PureComponent {
}
render() {
- const { height = '100px', id = 'graph' } = this.props;
+ const { height = 100, id = 'graph' } = this.props;
const { hiddenSeries } = this.state;
const data = this.getGraphData();
@@ -261,4 +260,4 @@ export class Graph extends PureComponent {
}
}
-export default withSize()(Graph);
+export default Graph;
diff --git a/public/app/features/explore/GraphContainer.tsx b/public/app/features/explore/GraphContainer.tsx
index 7263fd09288..3950d89c11f 100644
--- a/public/app/features/explore/GraphContainer.tsx
+++ b/public/app/features/explore/GraphContainer.tsx
@@ -20,6 +20,7 @@ interface GraphContainerProps {
split: boolean;
toggleGraph: typeof toggleGraph;
changeTime: typeof changeTime;
+ width: number;
}
export class GraphContainer extends PureComponent {
@@ -32,8 +33,8 @@ export class GraphContainer extends PureComponent {
};
render() {
- const { exploreId, graphResult, loading, showingGraph, showingTable, range, split } = this.props;
- const graphHeight = showingGraph && showingTable ? '200px' : '400px';
+ const { exploreId, graphResult, loading, showingGraph, showingTable, range, split, width } = this.props;
+ const graphHeight = showingGraph && showingTable ? 200 : 400;
if (!graphResult) {
return null;
@@ -48,6 +49,7 @@ export class GraphContainer extends PureComponent {
onChangeTime={this.onChangeTime}
range={range}
split={split}
+ width={width}
/>
);
diff --git a/public/app/features/explore/Logs.tsx b/public/app/features/explore/Logs.tsx
index 490257cb9a9..b6c903bc504 100644
--- a/public/app/features/explore/Logs.tsx
+++ b/public/app/features/explore/Logs.tsx
@@ -214,7 +214,7 @@ export default class Logs extends PureComponent {
{
+ const pastedValue = event.clipboardData.getData('Text');
+ const newValue = change.value.change().insertText(pastedValue);
+ this.onChange(newValue);
+
+ return true;
+ };
+
render() {
const { disabled } = this.props;
const wrapperClassName = classnames('slate-query-field__wrapper', {
@@ -484,6 +492,7 @@ export class QueryField extends React.PureComponent
@@ -480,7 +480,7 @@ exports[`Render should render component with disclaimer 1`] = `
id="graph"
style={
Object {
- "height": "100px",
+ "height": 100,
}
}
/>
@@ -962,7 +962,7 @@ exports[`Render should show query return no time series 1`] = `
id="graph"
style={
Object {
- "height": "100px",
+ "height": 100,
}
}
/>
diff --git a/public/app/features/panel/panel_ctrl.ts b/public/app/features/panel/panel_ctrl.ts
index 6799b209147..41810ab21c0 100644
--- a/public/app/features/panel/panel_ctrl.ts
+++ b/public/app/features/panel/panel_ctrl.ts
@@ -7,6 +7,7 @@ import { Emitter } from 'app/core/core';
import getFactors from 'app/core/utils/factors';
import {
duplicatePanel,
+ removePanel,
copyPanel as copyPanelUtil,
editPanelJson as editPanelJsonUtil,
sharePanel as sharePanelUtil,
@@ -213,9 +214,7 @@ export class PanelCtrl {
}
removePanel() {
- this.publishAppEvent('panel-remove', {
- panelId: this.panel.id,
- });
+ removePanel(this.dashboard, this.panel, true);
}
editPanelJson() {
diff --git a/public/app/features/playlist/playlist_srv.ts b/public/app/features/playlist/playlist_srv.ts
index 0a80ce0cdf0..6c1cf2b4256 100644
--- a/public/app/features/playlist/playlist_srv.ts
+++ b/public/app/features/playlist/playlist_srv.ts
@@ -1,12 +1,16 @@
-import coreModule from '../../core/core_module';
-import kbn from 'app/core/utils/kbn';
-import appEvents from 'app/core/app_events';
+// Libraries
import _ from 'lodash';
+
+// Utils
import { toUrlParams } from 'app/core/utils/url';
+import coreModule from '../../core/core_module';
+import appEvents from 'app/core/app_events';
+import locationUtil from 'app/core/utils/location_util';
+import kbn from 'app/core/utils/kbn';
export class PlaylistSrv {
private cancelPromise: any;
- private dashboards: Array<{ uri: string }>;
+ private dashboards: Array<{ url: string }>;
private index: number;
private interval: number;
private startUrl: string;
@@ -36,7 +40,12 @@ export class PlaylistSrv {
const queryParams = this.$location.search();
const filteredParams = _.pickBy(queryParams, value => value !== null);
- this.$location.url('dashboard/' + dash.uri + '?' + toUrlParams(filteredParams));
+ // this is done inside timeout to make sure digest happens after
+ // as this can be called from react
+ this.$timeout(() => {
+ const stripedUrl = locationUtil.stripBaseFromUrl(dash.url);
+ this.$location.url(stripedUrl + '?' + toUrlParams(filteredParams));
+ });
this.index++;
this.cancelPromise = this.$timeout(() => this.next(), this.interval);
@@ -54,6 +63,8 @@ export class PlaylistSrv {
this.index = 0;
this.isPlaying = true;
+ appEvents.emit('playlist-started');
+
return this.backendSrv.get(`/api/playlists/${playlistId}`).then(playlist => {
return this.backendSrv.get(`/api/playlists/${playlistId}/dashboards`).then(dashboards => {
this.dashboards = dashboards;
@@ -77,6 +88,8 @@ export class PlaylistSrv {
if (this.cancelPromise) {
this.$timeout.cancel(this.cancelPromise);
}
+
+ appEvents.emit('playlist-stopped');
}
}
diff --git a/public/app/features/playlist/specs/playlist_srv.test.ts b/public/app/features/playlist/specs/playlist_srv.test.ts
index e6b7671c964..d2ff27e54e0 100644
--- a/public/app/features/playlist/specs/playlist_srv.test.ts
+++ b/public/app/features/playlist/specs/playlist_srv.test.ts
@@ -1,6 +1,6 @@
import { PlaylistSrv } from '../playlist_srv';
-const dashboards = [{ uri: 'dash1' }, { uri: 'dash2' }];
+const dashboards = [{ url: 'dash1' }, { url: 'dash2' }];
const createPlaylistSrv = (): [PlaylistSrv, { url: jest.MockInstance }] => {
const mockBackendSrv = {
@@ -50,13 +50,12 @@ const mockWindowLocation = (): [jest.MockInstance, () => void] => {
describe('PlaylistSrv', () => {
let srv: PlaylistSrv;
- let mockLocationService: { url: jest.MockInstance };
let hrefMock: jest.MockInstance;
let unmockLocation: () => void;
const initialUrl = 'http://localhost/playlist';
beforeEach(() => {
- [srv, mockLocationService] = createPlaylistSrv();
+ [srv] = createPlaylistSrv();
[hrefMock, unmockLocation] = mockWindowLocation();
// This will be cached in the srv when start() is called
@@ -71,7 +70,6 @@ describe('PlaylistSrv', () => {
await srv.start(1);
for (let i = 0; i < 6; i++) {
- expect(mockLocationService.url).toHaveBeenLastCalledWith(`dashboard/${dashboards[i % 2].uri}?`);
srv.next();
}
@@ -84,7 +82,6 @@ describe('PlaylistSrv', () => {
// 1 complete loop
for (let i = 0; i < 3; i++) {
- expect(mockLocationService.url).toHaveBeenLastCalledWith(`dashboard/${dashboards[i % 2].uri}?`);
srv.next();
}
@@ -93,7 +90,6 @@ describe('PlaylistSrv', () => {
// Another 2 loops
for (let i = 0; i < 4; i++) {
- expect(mockLocationService.url).toHaveBeenLastCalledWith(`dashboard/${dashboards[i % 2].uri}?`);
srv.next();
}
diff --git a/public/app/features/profile/state/reducers.ts b/public/app/features/profile/state/reducers.ts
new file mode 100644
index 00000000000..dc6e841449e
--- /dev/null
+++ b/public/app/features/profile/state/reducers.ts
@@ -0,0 +1,14 @@
+import { UserState } from 'app/types';
+import config from 'app/core/config';
+
+export const initialState: UserState = {
+ orgId: config.bootData.user.orgId,
+};
+
+export const userReducer = (state = initialState, action: any): UserState => {
+ return state;
+};
+
+export default {
+ user: userReducer,
+};
diff --git a/public/app/features/templating/specs/variable_srv.test.ts b/public/app/features/templating/specs/variable_srv.test.ts
index db42df7f516..cf10235f6e8 100644
--- a/public/app/features/templating/specs/variable_srv.test.ts
+++ b/public/app/features/templating/specs/variable_srv.test.ts
@@ -48,7 +48,6 @@ describe('VariableSrv', function(this: any) {
ds.metricFindQuery = () => Promise.resolve(scenario.queryResult);
ctx.variableSrv = new VariableSrv(
- ctx.$rootScope,
$q,
ctx.$location,
ctx.$injector,
diff --git a/public/app/features/templating/specs/variable_srv_init.test.ts b/public/app/features/templating/specs/variable_srv_init.test.ts
index b8cabf711ac..480b5207c17 100644
--- a/public/app/features/templating/specs/variable_srv_init.test.ts
+++ b/public/app/features/templating/specs/variable_srv_init.test.ts
@@ -25,10 +25,6 @@ describe('VariableSrv init', function(this: any) {
};
const $injector = {} as any;
- const $rootscope = {
- $on: () => {},
- };
-
let ctx = {} as any;
function describeInitScenario(desc, fn) {
@@ -54,7 +50,7 @@ describe('VariableSrv init', function(this: any) {
};
// @ts-ignore
- ctx.variableSrv = new VariableSrv($rootscope, $q, {}, $injector, templateSrv, timeSrv);
+ ctx.variableSrv = new VariableSrv($q, {}, $injector, templateSrv, timeSrv);
$injector.instantiate = (variable, model) => {
return getVarMockConstructor(variable, model, ctx);
diff --git a/public/app/features/templating/template_srv.ts b/public/app/features/templating/template_srv.ts
index de5efabcf23..11e90cbb5f7 100644
--- a/public/app/features/templating/template_srv.ts
+++ b/public/app/features/templating/template_srv.ts
@@ -156,6 +156,9 @@ export class TemplateSrv {
}
return value;
}
+ case 'json': {
+ return JSON.stringify(value);
+ }
case 'percentencode': {
// like glob, but url escaped
if (_.isArray(value)) {
diff --git a/public/app/features/templating/variable_srv.ts b/public/app/features/templating/variable_srv.ts
index b2f8b43fb08..81e4e0a4a0b 100644
--- a/public/app/features/templating/variable_srv.ts
+++ b/public/app/features/templating/variable_srv.ts
@@ -18,18 +18,18 @@ export class VariableSrv {
variables: any[];
/** @ngInject */
- constructor(private $rootScope,
- private $q,
+ constructor(private $q,
private $location,
private $injector,
private templateSrv: TemplateSrv,
private timeSrv: TimeSrv) {
- $rootScope.$on('template-variable-value-updated', this.updateUrlParamsWithCurrentVariables.bind(this), $rootScope);
+
}
init(dashboard: DashboardModel) {
this.dashboard = dashboard;
this.dashboard.events.on('time-range-updated', this.onTimeRangeUpdated.bind(this));
+ this.dashboard.events.on('template-variable-value-updated', this.updateUrlParamsWithCurrentVariables.bind(this));
// create working class models representing variables
this.variables = dashboard.templating.list = dashboard.templating.list.map(this.createVariableFromModel.bind(this));
@@ -59,7 +59,7 @@ export class VariableSrv {
return variable.updateOptions().then(() => {
if (angular.toJson(previousOptions) !== angular.toJson(variable.options)) {
- this.$rootScope.$emit('template-variable-value-updated');
+ this.dashboard.templateVariableValueUpdated();
}
});
});
@@ -144,7 +144,7 @@ export class VariableSrv {
return this.$q.all(promises).then(() => {
if (emitChangeEvents) {
- this.$rootScope.appEvent('template-variable-value-updated');
+ this.dashboard.templateVariableValueUpdated();
this.dashboard.startRefresh();
}
});
diff --git a/public/app/partials/dashboard.html b/public/app/partials/dashboard.html
deleted file mode 100644
index 32acdc435f2..00000000000
--- a/public/app/partials/dashboard.html
+++ /dev/null
@@ -1,17 +0,0 @@
-
diff --git a/public/app/plugins/datasource/cloudwatch/datasource.ts b/public/app/plugins/datasource/cloudwatch/datasource.ts
index d9fb3450524..a098800c77b 100644
--- a/public/app/plugins/datasource/cloudwatch/datasource.ts
+++ b/public/app/plugins/datasource/cloudwatch/datasource.ts
@@ -232,6 +232,14 @@ export default class CloudWatchDatasource {
});
}
+ getResourceARNs(region, resourceType, tags) {
+ return this.doMetricQueryRequest('resource_arns', {
+ region: this.templateSrv.replace(this.getActualRegion(region)),
+ resourceType: this.templateSrv.replace(resourceType),
+ tags: tags,
+ });
+ }
+
metricFindQuery(query) {
let region;
let namespace;
@@ -293,6 +301,15 @@ export default class CloudWatchDatasource {
return this.getEc2InstanceAttribute(region, targetAttributeName, filterJson);
}
+
+ const resourceARNsQuery = query.match(/^resource_arns\(([^,]+?),\s?([^,]+?),\s?(.+?)\)/);
+ if (resourceARNsQuery) {
+ region = resourceARNsQuery[1];
+ const resourceType = resourceARNsQuery[2];
+ const tagsJSON = JSON.parse(this.templateSrv.replace(resourceARNsQuery[3]));
+ return this.getResourceARNs(region, resourceType, tagsJSON);
+ }
+
return this.$q.when([]);
}
diff --git a/public/app/plugins/datasource/cloudwatch/specs/datasource.test.ts b/public/app/plugins/datasource/cloudwatch/specs/datasource.test.ts
index 512767075a9..1b2a2f4eead 100644
--- a/public/app/plugins/datasource/cloudwatch/specs/datasource.test.ts
+++ b/public/app/plugins/datasource/cloudwatch/specs/datasource.test.ts
@@ -380,6 +380,29 @@ describe('CloudWatchDatasource', () => {
});
});
+ describeMetricFindQuery('resource_arns(default,ec2:instance,{"environment":["production"]})', scenario => {
+ scenario.setup(() => {
+ scenario.requestResponse = {
+ results: {
+ metricFindQuery: {
+ tables: [{
+ rows: [[
+ 'arn:aws:ec2:us-east-1:123456789012:instance/i-12345678901234567',
+ 'arn:aws:ec2:us-east-1:123456789012:instance/i-76543210987654321'
+ ]]
+ }],
+ },
+ },
+ };
+ });
+
+ it('should call __ListMetrics and return result', () => {
+ expect(scenario.result[0].text).toContain('arn:aws:ec2:us-east-1:123456789012:instance/i-12345678901234567');
+ expect(scenario.request.queries[0].type).toBe('metricFindQuery');
+ expect(scenario.request.queries[0].subtype).toBe('resource_arns');
+ });
+ });
+
it('should caclculate the correct period', () => {
const hourSec = 60 * 60;
const daySec = hourSec * 24;
diff --git a/public/app/plugins/datasource/mssql/datasource.ts b/public/app/plugins/datasource/mssql/datasource.ts
index 23aa5504d3e..303cd0471d7 100644
--- a/public/app/plugins/datasource/mssql/datasource.ts
+++ b/public/app/plugins/datasource/mssql/datasource.ts
@@ -8,7 +8,7 @@ export class MssqlDatasource {
interval: string;
/** @ngInject */
- constructor(instanceSettings, private backendSrv, private $q, private templateSrv) {
+ constructor(instanceSettings, private backendSrv, private $q, private templateSrv, private timeSrv) {
this.name = instanceSettings.name;
this.id = instanceSettings.id;
this.responseParser = new ResponseParser(this.$q);
@@ -107,13 +107,18 @@ export class MssqlDatasource {
format: 'table',
};
+ const range = this.timeSrv.timeRange();
+ const data = {
+ queries: [interpolatedQuery],
+ from: range.from.valueOf().toString(),
+ to: range.to.valueOf().toString(),
+ };
+
return this.backendSrv
.datasourceRequest({
url: '/api/tsdb/query',
method: 'POST',
- data: {
- queries: [interpolatedQuery],
- },
+ data: data,
})
.then(data => this.responseParser.parseMetricFindQueryResult(refId, data));
}
diff --git a/public/app/plugins/datasource/mssql/specs/datasource.test.ts b/public/app/plugins/datasource/mssql/specs/datasource.test.ts
index 0dd496bfe59..a05848b3da8 100644
--- a/public/app/plugins/datasource/mssql/specs/datasource.test.ts
+++ b/public/app/plugins/datasource/mssql/specs/datasource.test.ts
@@ -1,6 +1,6 @@
import moment from 'moment';
import { MssqlDatasource } from '../datasource';
-import { TemplateSrvStub } from 'test/specs/helpers';
+import { TemplateSrvStub, TimeSrvStub } from 'test/specs/helpers';
import { CustomVariable } from 'app/features/templating/custom_variable';
import q from 'q';
@@ -8,13 +8,14 @@ describe('MSSQLDatasource', () => {
const ctx: any = {
backendSrv: {},
templateSrv: new TemplateSrvStub(),
+ timeSrv: new TimeSrvStub(),
};
beforeEach(() => {
ctx.$q = q;
ctx.instanceSettings = { name: 'mssql' };
- ctx.ds = new MssqlDatasource(ctx.instanceSettings, ctx.backendSrv, ctx.$q, ctx.templateSrv);
+ ctx.ds = new MssqlDatasource(ctx.instanceSettings, ctx.backendSrv, ctx.$q, ctx.templateSrv, ctx.timeSrv);
});
describe('When performing annotationQuery', () => {
@@ -188,6 +189,49 @@ describe('MSSQLDatasource', () => {
});
});
+ describe('When performing metricFindQuery', () => {
+ let results;
+ const query = 'select * from atable';
+ const response = {
+ results: {
+ tempvar: {
+ meta: {
+ rowCount: 1,
+ },
+ refId: 'tempvar',
+ tables: [
+ {
+ columns: [{ text: 'title' }],
+ rows: [['aTitle']],
+ },
+ ],
+ },
+ },
+ };
+ const time = {
+ from: moment(1521545610656),
+ to: moment(1521546251185)
+ };
+
+ beforeEach(() => {
+ ctx.timeSrv.setTime(time);
+
+ ctx.backendSrv.datasourceRequest = options => {
+ results = options.data;
+ return ctx.$q.when({ data: response, status: 200 });
+ };
+
+ return ctx.ds.metricFindQuery(query);
+ });
+
+ it('should pass timerange to datasourceRequest', () => {
+ expect(results.from).toBe(time.from.valueOf().toString());
+ expect(results.to).toBe(time.to.valueOf().toString());
+ expect(results.queries.length).toBe(1);
+ expect(results.queries[0].rawSql).toBe(query);
+ });
+ });
+
describe('When interpolating variables', () => {
beforeEach(() => {
ctx.variable = new CustomVariable({}, {});
diff --git a/public/app/plugins/datasource/mysql/partials/config.html b/public/app/plugins/datasource/mysql/partials/config.html
index a35633c626a..8221a06e1ee 100644
--- a/public/app/plugins/datasource/mysql/partials/config.html
+++ b/public/app/plugins/datasource/mysql/partials/config.html
@@ -1,4 +1,3 @@
-
MySQL Connection
@@ -22,7 +21,23 @@
-
+
+
+
+
+
Connection limits
@@ -84,4 +99,3 @@
-
diff --git a/public/app/plugins/datasource/stackdriver/components/QueryEditor.tsx b/public/app/plugins/datasource/stackdriver/components/QueryEditor.tsx
index 94521041416..c3bd9212b21 100644
--- a/public/app/plugins/datasource/stackdriver/components/QueryEditor.tsx
+++ b/public/app/plugins/datasource/stackdriver/components/QueryEditor.tsx
@@ -10,21 +10,21 @@ import { Alignments } from './Alignments';
import { AlignmentPeriods } from './AlignmentPeriods';
import { AliasBy } from './AliasBy';
import { Help } from './Help';
-import { Target, MetricDescriptor } from '../types';
+import { StackdriverQuery, MetricDescriptor } from '../types';
import { getAlignmentPickerData } from '../functions';
import StackdriverDatasource from '../datasource';
import { SelectOptionItem } from '@grafana/ui';
export interface Props {
- onQueryChange: (target: Target) => void;
+ onQueryChange: (target: StackdriverQuery) => void;
onExecuteQuery: () => void;
- target: Target;
+ target: StackdriverQuery;
events: any;
datasource: StackdriverDatasource;
templateSrv: TemplateSrv;
}
-interface State extends Target {
+interface State extends StackdriverQuery {
alignOptions: SelectOptionItem[];
lastQuery: string;
lastQueryError: string;
diff --git a/public/app/plugins/datasource/stackdriver/datasource.ts b/public/app/plugins/datasource/stackdriver/datasource.ts
index 025955105a7..4c1f07e0a06 100644
--- a/public/app/plugins/datasource/stackdriver/datasource.ts
+++ b/public/app/plugins/datasource/stackdriver/datasource.ts
@@ -2,9 +2,10 @@ import { stackdriverUnitMappings } from './constants';
import appEvents from 'app/core/app_events';
import _ from 'lodash';
import StackdriverMetricFindQuery from './StackdriverMetricFindQuery';
-import { MetricDescriptor } from './types';
+import { StackdriverQuery, MetricDescriptor } from './types';
+import { DataSourceApi, DataQueryOptions } from '@grafana/ui/src/types';
-export default class StackdriverDatasource {
+export default class StackdriverDatasource implements DataSourceApi {
id: number;
url: string;
baseUrl: string;
@@ -39,9 +40,7 @@ export default class StackdriverDatasource {
alignmentPeriod: this.templateSrv.replace(t.alignmentPeriod, options.scopedVars || {}),
groupBys: this.interpolateGroupBys(t.groupBys, options.scopedVars),
view: t.view || 'FULL',
- filters: (t.filters || []).map(f => {
- return this.templateSrv.replace(f, options.scopedVars || {});
- }),
+ filters: this.interpolateFilters(t.filters, options.scopedVars),
aliasBy: this.templateSrv.replace(t.aliasBy, options.scopedVars || {}),
type: 'timeSeriesQuery',
};
@@ -63,7 +62,13 @@ export default class StackdriverDatasource {
}
}
- async getLabels(metricType, refId) {
+ interpolateFilters(filters: string[], scopedVars: object) {
+ return (filters || []).map(f => {
+ return this.templateSrv.replace(f, scopedVars || {}, 'regex');
+ });
+ }
+
+ async getLabels(metricType: string, refId: string) {
const response = await this.getTimeSeries({
targets: [
{
@@ -103,7 +108,7 @@ export default class StackdriverDatasource {
return unit;
}
- async query(options) {
+ async query(options: DataQueryOptions) {
const result = [];
const data = await this.getTimeSeries(options);
if (data.results) {
diff --git a/public/app/plugins/datasource/stackdriver/query_ctrl.ts b/public/app/plugins/datasource/stackdriver/query_ctrl.ts
index c6a8a4d9782..3a2d0bb970a 100644
--- a/public/app/plugins/datasource/stackdriver/query_ctrl.ts
+++ b/public/app/plugins/datasource/stackdriver/query_ctrl.ts
@@ -1,7 +1,7 @@
import _ from 'lodash';
import { QueryCtrl } from 'app/plugins/sdk';
-import { Target } from './types';
+import { StackdriverQuery } from './types';
import { TemplateSrv } from 'app/features/templating/template_srv';
export class StackdriverQueryCtrl extends QueryCtrl {
@@ -16,7 +16,7 @@ export class StackdriverQueryCtrl extends QueryCtrl {
this.onExecuteQuery = this.onExecuteQuery.bind(this);
}
- onQueryChange(target: Target) {
+ onQueryChange(target: StackdriverQuery) {
Object.assign(this.target, target);
}
diff --git a/public/app/plugins/datasource/stackdriver/specs/datasource.test.ts b/public/app/plugins/datasource/stackdriver/specs/datasource.test.ts
index 46cdd77b7a9..032f10d8ca5 100644
--- a/public/app/plugins/datasource/stackdriver/specs/datasource.test.ts
+++ b/public/app/plugins/datasource/stackdriver/specs/datasource.test.ts
@@ -1,7 +1,8 @@
import StackdriverDataSource from '../datasource';
import { metricDescriptors } from './testData';
import moment from 'moment';
-import { TemplateSrvStub } from 'test/specs/helpers';
+import { TemplateSrv } from 'app/features/templating/template_srv';
+import { CustomVariable } from 'app/features/templating/all';
describe('StackdriverDataSource', () => {
const instanceSettings = {
@@ -9,7 +10,7 @@ describe('StackdriverDataSource', () => {
defaultProject: 'testproject',
},
};
- const templateSrv = new TemplateSrvStub();
+ const templateSrv = new TemplateSrv();
const timeSrv = {};
describe('when performing testDataSource', () => {
@@ -154,15 +155,41 @@ describe('StackdriverDataSource', () => {
});
});
+ describe('when interpolating a template variable for the filter', () => {
+ let interpolated;
+ describe('and is single value variable', () => {
+ beforeEach(() => {
+ const filterTemplateSrv = initTemplateSrv('filtervalue1');
+ const ds = new StackdriverDataSource(instanceSettings, {}, filterTemplateSrv, timeSrv);
+ interpolated = ds.interpolateFilters(['resource.label.zone', '=~', '${test}'], {});
+ });
+
+ it('should replace the variable with the value', () => {
+ expect(interpolated.length).toBe(3);
+ expect(interpolated[2]).toBe('filtervalue1');
+ });
+ });
+
+ describe('and is multi value variable', () => {
+ beforeEach(() => {
+ const filterTemplateSrv = initTemplateSrv(['filtervalue1', 'filtervalue2'], true);
+ const ds = new StackdriverDataSource(instanceSettings, {}, filterTemplateSrv, timeSrv);
+ interpolated = ds.interpolateFilters(['resource.label.zone', '=~', '[[test]]'], {});
+ });
+
+ it('should replace the variable with a regex expression', () => {
+ expect(interpolated[2]).toBe('(filtervalue1|filtervalue2)');
+ });
+ });
+ });
+
describe('when interpolating a template variable for group bys', () => {
let interpolated;
describe('and is single value variable', () => {
beforeEach(() => {
- templateSrv.data = {
- test: 'groupby1',
- };
- const ds = new StackdriverDataSource(instanceSettings, {}, templateSrv, timeSrv);
+ const groupByTemplateSrv = initTemplateSrv('groupby1');
+ const ds = new StackdriverDataSource(instanceSettings, {}, groupByTemplateSrv, timeSrv);
interpolated = ds.interpolateGroupBys(['[[test]]'], {});
});
@@ -174,10 +201,8 @@ describe('StackdriverDataSource', () => {
describe('and is multi value variable', () => {
beforeEach(() => {
- templateSrv.data = {
- test: 'groupby1,groupby2',
- };
- const ds = new StackdriverDataSource(instanceSettings, {}, templateSrv, timeSrv);
+ const groupByTemplateSrv = initTemplateSrv(['groupby1', 'groupby2'], true);
+ const ds = new StackdriverDataSource(instanceSettings, {}, groupByTemplateSrv, timeSrv);
interpolated = ds.interpolateGroupBys(['[[test]]'], {});
});
@@ -241,3 +266,19 @@ describe('StackdriverDataSource', () => {
});
});
});
+function initTemplateSrv(values: any, multi = false) {
+ const templateSrv = new TemplateSrv();
+ templateSrv.init([
+ new CustomVariable(
+ {
+ name: 'test',
+ current: {
+ value: values,
+ },
+ multi: multi,
+ },
+ {}
+ ),
+ ]);
+ return templateSrv;
+}
diff --git a/public/app/plugins/datasource/stackdriver/types.ts b/public/app/plugins/datasource/stackdriver/types.ts
index 29b12b4289d..b9a6893d4bd 100644
--- a/public/app/plugins/datasource/stackdriver/types.ts
+++ b/public/app/plugins/datasource/stackdriver/types.ts
@@ -1,3 +1,5 @@
+import { DataQuery } from '@grafana/ui/src/types';
+
export enum MetricFindQueryTypes {
Services = 'services',
MetricTypes = 'metricTypes',
@@ -20,20 +22,22 @@ export interface VariableQueryData {
services: Array<{ value: string; name: string }>;
}
-export interface Target {
- defaultProject: string;
- unit: string;
+export interface StackdriverQuery extends DataQuery {
+ defaultProject?: string;
+ unit?: string;
metricType: string;
- service: string;
+ service?: string;
refId: string;
crossSeriesReducer: string;
- alignmentPeriod: string;
+ alignmentPeriod?: string;
perSeriesAligner: string;
- groupBys: string[];
- filters: string[];
- aliasBy: string;
+ groupBys?: string[];
+ filters?: string[];
+ aliasBy?: string;
metricKind: string;
valueType: string;
+ datasourceId?: number;
+ view?: string;
}
export interface AnnotationTarget {
diff --git a/public/app/plugins/panel/gauge/GaugePanel.tsx b/public/app/plugins/panel/gauge/GaugePanel.tsx
index b6f37dde94f..5cb256ee1aa 100644
--- a/public/app/plugins/panel/gauge/GaugePanel.tsx
+++ b/public/app/plugins/panel/gauge/GaugePanel.tsx
@@ -2,7 +2,7 @@
import React, { PureComponent } from 'react';
// Services & Utils
-import { processTimeSeries } from '@grafana/ui';
+import { processTimeSeries, ThemeContext } from '@grafana/ui';
// Components
import { Gauge } from '@grafana/ui';
@@ -10,7 +10,6 @@ import { Gauge } from '@grafana/ui';
// Types
import { GaugeOptions } from './types';
import { PanelProps, NullValueMode, TimeSeriesValue } from '@grafana/ui/src/types';
-import { ThemeProvider } from 'app/core/utils/ConfigProvider';
interface Props extends PanelProps {}
@@ -38,7 +37,7 @@ export class GaugePanel extends PureComponent {
}
return (
-
+
{theme => (
{
theme={theme}
/>
)}
-
+
);
}
}
diff --git a/public/app/plugins/panel/gauge/GaugePanelOptions.tsx b/public/app/plugins/panel/gauge/GaugePanelOptions.tsx
index 655c596ce84..84726ac88bf 100644
--- a/public/app/plugins/panel/gauge/GaugePanelOptions.tsx
+++ b/public/app/plugins/panel/gauge/GaugePanelOptions.tsx
@@ -11,7 +11,6 @@ import {
import ValueOptions from 'app/plugins/panel/gauge/ValueOptions';
import GaugeOptionsEditor from './GaugeOptionsEditor';
import { GaugeOptions } from './types';
-import { ThemeProvider } from 'app/core/utils/ConfigProvider';
export const defaultProps = {
options: {
@@ -46,24 +45,17 @@ export default class GaugePanelOptions extends PureComponent
- {(theme) => (
- <>
-
-
-
-
-
-
- >
- )}
-
+ return (
+ <>
+
+
+
+
+
+
+
+ >
);
}
}
diff --git a/public/app/plugins/panel/graph/Legend/LegendSeriesItem.tsx b/public/app/plugins/panel/graph/Legend/LegendSeriesItem.tsx
index d62613319b2..2cf45727c4a 100644
--- a/public/app/plugins/panel/graph/Legend/LegendSeriesItem.tsx
+++ b/public/app/plugins/panel/graph/Legend/LegendSeriesItem.tsx
@@ -2,7 +2,6 @@ import React, { PureComponent } from 'react';
import classNames from 'classnames';
import { TimeSeries } from 'app/core/core';
import { SeriesColorPicker } from '@grafana/ui';
-import { ThemeProvider } from 'app/core/utils/ConfigProvider';
export const LEGEND_STATS = ['min', 'max', 'avg', 'current', 'total'];
@@ -168,24 +167,17 @@ class LegendSeriesIcon extends PureComponent
- {theme => {
- return (
-
-
-
-
-
- );
- }}
-
+
+
+
+
+
);
}
}
diff --git a/public/app/plugins/panel/graph/data_processor.ts b/public/app/plugins/panel/graph/data_processor.ts
index 4141d36e273..2966bb33eb4 100644
--- a/public/app/plugins/panel/graph/data_processor.ts
+++ b/public/app/plugins/panel/graph/data_processor.ts
@@ -1,5 +1,5 @@
import _ from 'lodash';
-import { colors, GrafanaTheme, getColorFromHexRgbOrName } from '@grafana/ui';
+import { colors, getColorFromHexRgbOrName } from '@grafana/ui';
import TimeSeries from 'app/core/time_series2';
import config from 'app/core/config';
@@ -113,7 +113,7 @@ export class DataProcessor {
const series = new TimeSeries({
datapoints: datapoints,
alias: alias,
- color: getColorFromHexRgbOrName(color, config.bootData.user.lightTheme ? GrafanaTheme.Light : GrafanaTheme.Dark),
+ color: getColorFromHexRgbOrName(color, config.theme.type),
unit: seriesData.unit,
});
diff --git a/public/app/plugins/panel/graph/graph.ts b/public/app/plugins/panel/graph/graph.ts
index aeb540551b8..54ba4ed1e6f 100755
--- a/public/app/plugins/panel/graph/graph.ts
+++ b/public/app/plugins/panel/graph/graph.ts
@@ -25,7 +25,10 @@ import ReactDOM from 'react-dom';
import { Legend, GraphLegendProps } from './Legend/Legend';
import { GraphCtrl } from './module';
-import { GrafanaTheme, getValueFormat } from '@grafana/ui';
+import { getValueFormat } from '@grafana/ui';
+import { provideTheme } from 'app/core/utils/ConfigProvider';
+
+const LegendWithThemeProvider = provideTheme(Legend);
class GraphElement {
ctrl: GraphCtrl;
@@ -43,6 +46,7 @@ class GraphElement {
legendElem: HTMLElement;
constructor(private scope, private elem, private timeSrv) {
+
this.ctrl = scope.ctrl;
this.dashboard = this.ctrl.dashboard;
this.panel = this.ctrl.panel;
@@ -51,10 +55,7 @@ class GraphElement {
this.panelWidth = 0;
this.eventManager = new EventManager(this.ctrl);
this.thresholdManager = new ThresholdManager(this.ctrl);
- this.timeRegionManager = new TimeRegionManager(
- this.ctrl,
- config.bootData.user.lightTheme ? GrafanaTheme.Light : GrafanaTheme.Dark
- );
+ this.timeRegionManager = new TimeRegionManager(this.ctrl, config.theme.type);
this.tooltip = new GraphTooltip(this.elem, this.ctrl.dashboard, this.scope, () => {
return this.sortedSeries;
});
@@ -109,7 +110,7 @@ class GraphElement {
onToggleAxis: this.ctrl.onToggleAxis,
};
- const legendReactElem = React.createElement(Legend, legendProps);
+ const legendReactElem = React.createElement(LegendWithThemeProvider, legendProps);
ReactDOM.render(legendReactElem, this.legendElem, () => this.renderPanel());
}
diff --git a/public/app/plugins/panel/graph/module.ts b/public/app/plugins/panel/graph/module.ts
index 68d982eab13..3919c4f69a9 100644
--- a/public/app/plugins/panel/graph/module.ts
+++ b/public/app/plugins/panel/graph/module.ts
@@ -10,7 +10,7 @@ import { MetricsPanelCtrl } from 'app/plugins/sdk';
import { DataProcessor } from './data_processor';
import { axesEditorComponent } from './axes_editor';
import config from 'app/core/config';
-import { GrafanaTheme, getColorFromHexRgbOrName } from '@grafana/ui';
+import { getColorFromHexRgbOrName } from '@grafana/ui';
class GraphCtrl extends MetricsPanelCtrl {
static template = template;
@@ -244,7 +244,7 @@ class GraphCtrl extends MetricsPanelCtrl {
}
onColorChange = (series, color) => {
- series.setColor(getColorFromHexRgbOrName(color, config.bootData.user.lightTheme ? GrafanaTheme.Light : GrafanaTheme.Dark));
+ series.setColor(getColorFromHexRgbOrName(color, config.theme.type));
this.panel.aliasColors[series.alias] = color;
this.render();
};
diff --git a/public/app/plugins/panel/graph/time_region_manager.ts b/public/app/plugins/panel/graph/time_region_manager.ts
index 2917583ff36..ea39927bf57 100644
--- a/public/app/plugins/panel/graph/time_region_manager.ts
+++ b/public/app/plugins/panel/graph/time_region_manager.ts
@@ -1,7 +1,7 @@
import 'vendor/flot/jquery.flot';
import _ from 'lodash';
import moment from 'moment';
-import { GrafanaTheme, getColorFromHexRgbOrName } from '@grafana/ui';
+import { GrafanaThemeType, getColorFromHexRgbOrName } from '@grafana/ui';
type TimeRegionColorDefinition = {
fill: string;
@@ -43,7 +43,7 @@ export function getColorModes() {
});
}
-function getColor(timeRegion, theme: GrafanaTheme): TimeRegionColorDefinition {
+function getColor(timeRegion, theme: GrafanaThemeType): TimeRegionColorDefinition {
if (Object.keys(colorModes).indexOf(timeRegion.colorMode) === -1) {
timeRegion.colorMode = 'red';
}
@@ -58,7 +58,7 @@ function getColor(timeRegion, theme: GrafanaTheme): TimeRegionColorDefinition {
const colorMode = colorModes[timeRegion.colorMode];
if (colorMode.themeDependent === true) {
- return theme === GrafanaTheme.Light ? colorMode.lightColor : colorMode.darkColor;
+ return theme === GrafanaThemeType.Light ? colorMode.lightColor : colorMode.darkColor;
}
return {
@@ -71,7 +71,7 @@ export class TimeRegionManager {
plot: any;
timeRegions: any;
- constructor(private panelCtrl, private theme: GrafanaTheme = GrafanaTheme.Dark) {}
+ constructor(private panelCtrl, private theme: GrafanaThemeType = GrafanaThemeType.Dark) {}
draw(plot) {
this.timeRegions = this.panelCtrl.panel.timeRegions;
diff --git a/public/app/plugins/panel/heatmap/color_legend.ts b/public/app/plugins/panel/heatmap/color_legend.ts
index 81329fe297b..dea250abf74 100644
--- a/public/app/plugins/panel/heatmap/color_legend.ts
+++ b/public/app/plugins/panel/heatmap/color_legend.ts
@@ -5,7 +5,7 @@ import { contextSrv } from 'app/core/core';
import { tickStep } from 'app/core/utils/ticks';
import { getColorScale, getOpacityScale } from './color_scale';
import coreModule from 'app/core/core_module';
-import { GrafanaTheme, getColorFromHexRgbOrName } from '@grafana/ui';
+import { GrafanaThemeType, getColorFromHexRgbOrName } from '@grafana/ui';
const LEGEND_HEIGHT_PX = 6;
const LEGEND_WIDTH_PX = 100;
@@ -250,7 +250,7 @@ function drawSimpleOpacityLegend(elem, options) {
.attr('stroke-width', 0)
.attr(
'fill',
- getColorFromHexRgbOrName(options.cardColor, contextSrv.user.lightTheme ? GrafanaTheme.Light : GrafanaTheme.Dark)
+ getColorFromHexRgbOrName(options.cardColor, contextSrv.user.lightTheme ? GrafanaThemeType.Light : GrafanaThemeType.Dark)
)
.style('opacity', d => legendOpacityScale(d));
}
diff --git a/public/app/plugins/panel/heatmap/rendering.ts b/public/app/plugins/panel/heatmap/rendering.ts
index 6489c9e9895..63604382432 100644
--- a/public/app/plugins/panel/heatmap/rendering.ts
+++ b/public/app/plugins/panel/heatmap/rendering.ts
@@ -7,7 +7,7 @@ import * as ticksUtils from 'app/core/utils/ticks';
import { HeatmapTooltip } from './heatmap_tooltip';
import { mergeZeroBuckets } from './heatmap_data_converter';
import { getColorScale, getOpacityScale } from './color_scale';
-import { GrafanaTheme, getColorFromHexRgbOrName, getValueFormat } from '@grafana/ui';
+import { GrafanaThemeType, getColorFromHexRgbOrName, getValueFormat } from '@grafana/ui';
const MIN_CARD_SIZE = 1,
CARD_PADDING = 1,
@@ -663,7 +663,7 @@ export class HeatmapRenderer {
if (this.panel.color.mode === 'opacity') {
return getColorFromHexRgbOrName(
this.panel.color.cardColor,
- contextSrv.user.lightTheme ? GrafanaTheme.Light : GrafanaTheme.Dark
+ contextSrv.user.lightTheme ? GrafanaThemeType.Light : GrafanaThemeType.Dark
);
} else {
return this.colorScale(d.count);
diff --git a/public/app/plugins/panel/singlestat/module.ts b/public/app/plugins/panel/singlestat/module.ts
index 2768951d2ba..21ab32278f8 100644
--- a/public/app/plugins/panel/singlestat/module.ts
+++ b/public/app/plugins/panel/singlestat/module.ts
@@ -8,7 +8,7 @@ import kbn from 'app/core/utils/kbn';
import config from 'app/core/config';
import TimeSeries from 'app/core/time_series2';
import { MetricsPanelCtrl } from 'app/plugins/sdk';
-import { GrafanaTheme, getValueFormat, getColorFromHexRgbOrName } from '@grafana/ui';
+import { GrafanaThemeType, getValueFormat, getColorFromHexRgbOrName } from '@grafana/ui';
class SingleStatCtrl extends MetricsPanelCtrl {
static templateUrl = 'module.html';
@@ -588,10 +588,7 @@ class SingleStatCtrl extends MetricsPanelCtrl {
fill: 1,
zero: false,
lineWidth: 1,
- fillColor: getColorFromHexRgbOrName(
- panel.sparkline.fillColor,
- config.bootData.user.lightTheme ? GrafanaTheme.Light : GrafanaTheme.Dark
- ),
+ fillColor: getColorFromHexRgbOrName(panel.sparkline.fillColor, config.theme.type),
},
},
yaxes: { show: false },
@@ -608,10 +605,7 @@ class SingleStatCtrl extends MetricsPanelCtrl {
const plotSeries = {
data: data.flotpairs,
- color: getColorFromHexRgbOrName(
- panel.sparkline.lineColor,
- config.bootData.user.lightTheme ? GrafanaTheme.Light : GrafanaTheme.Dark
- ),
+ color: getColorFromHexRgbOrName(panel.sparkline.lineColor, config.theme.type),
};
$.plot(plotCanvas, [plotSeries], options);
@@ -630,7 +624,7 @@ class SingleStatCtrl extends MetricsPanelCtrl {
// Map panel colors to hex or rgb/a values
data.colorMap = panel.colors.map(color =>
- getColorFromHexRgbOrName(color, config.bootData.user.lightTheme ? GrafanaTheme.Light : GrafanaTheme.Dark)
+ getColorFromHexRgbOrName(color, config.bootData.user.lightTheme ? GrafanaThemeType.Light : GrafanaThemeType.Dark)
);
const body = panel.gauge.show ? '' : getBigValueHtml();
diff --git a/public/app/plugins/panel/table/module.ts b/public/app/plugins/panel/table/module.ts
index 82763e1839a..268f5aa7ac4 100644
--- a/public/app/plugins/panel/table/module.ts
+++ b/public/app/plugins/panel/table/module.ts
@@ -6,7 +6,6 @@ import { transformDataToTable } from './transformers';
import { tablePanelEditor } from './editor';
import { columnOptionsTab } from './column_options';
import { TableRenderer } from './renderer';
-import { GrafanaTheme } from '@grafana/ui';
class TablePanelCtrl extends MetricsPanelCtrl {
static templateUrl = 'module.html';
@@ -131,7 +130,7 @@ class TablePanelCtrl extends MetricsPanelCtrl {
this.dashboard.isTimezoneUtc(),
this.$sanitize,
this.templateSrv,
- config.bootData.user.lightTheme ? GrafanaTheme.Light : GrafanaTheme.Dark,
+ config.theme.type
);
return super.render(this.table);
diff --git a/public/app/plugins/panel/table/renderer.ts b/public/app/plugins/panel/table/renderer.ts
index 90479a67602..e9bf89f45fe 100644
--- a/public/app/plugins/panel/table/renderer.ts
+++ b/public/app/plugins/panel/table/renderer.ts
@@ -1,7 +1,7 @@
import _ from 'lodash';
import moment from 'moment';
import kbn from 'app/core/utils/kbn';
-import { GrafanaTheme, getValueFormat, getColorFromHexRgbOrName } from '@grafana/ui';
+import { getValueFormat, getColorFromHexRgbOrName, GrafanaThemeType } from '@grafana/ui';
export class TableRenderer {
formatters: any[];
@@ -13,7 +13,7 @@ export class TableRenderer {
private isUtc,
private sanitize,
private templateSrv,
- private theme?: GrafanaTheme
+ private theme?: GrafanaThemeType
) {
this.initColumns();
}
diff --git a/public/app/routes/GrafanaCtrl.ts b/public/app/routes/GrafanaCtrl.ts
index 70bdf49e5e4..a6d97856e74 100644
--- a/public/app/routes/GrafanaCtrl.ts
+++ b/public/app/routes/GrafanaCtrl.ts
@@ -1,9 +1,11 @@
-import config from 'app/core/config';
+// Libraries
import _ from 'lodash';
import $ from 'jquery';
import Drop from 'tether-drop';
-import { colors } from '@grafana/ui';
+// Utils and servies
+import { colors } from '@grafana/ui';
+import config from 'app/core/config';
import coreModule from 'app/core/core_module';
import { profiler } from 'app/core/profiler';
import appEvents from 'app/core/app_events';
@@ -13,6 +15,9 @@ import { DatasourceSrv, setDatasourceSrv } from 'app/features/plugins/datasource
import { AngularLoader, setAngularLoader } from 'app/core/services/AngularLoader';
import { configureStore } from 'app/store/configureStore';
+// Types
+import { KioskUrlValue } from 'app/types';
+
export class GrafanaCtrl {
/** @ngInject */
constructor(
@@ -46,11 +51,6 @@ export class GrafanaCtrl {
$rootScope.colors = colors;
- $scope.initDashboard = (dashboardData, viewScope) => {
- $scope.appEvent('dashboard-fetch-end', dashboardData);
- $controller('DashboardCtrl', { $scope: viewScope }).init(dashboardData);
- };
-
$rootScope.onAppEvent = function(name, callback, localScope) {
const unbind = $rootScope.$on(name, callback);
let callerScope = this;
@@ -72,7 +72,7 @@ export class GrafanaCtrl {
}
}
-function setViewModeBodyClass(body, mode, sidemenuOpen: boolean) {
+function setViewModeBodyClass(body, mode: KioskUrlValue, sidemenuOpen: boolean) {
body.removeClass('view-mode--tv');
body.removeClass('view-mode--kiosk');
body.removeClass('view-mode--inactive');
@@ -126,12 +126,13 @@ export function grafanaAppDirective(playlistSrv, contextSrv, $timeout, $rootScop
body.toggleClass('sidemenu-hidden');
});
- scope.$watch(
- () => playlistSrv.isPlaying,
- newValue => {
- elem.toggleClass('view-mode--playlist', newValue === true);
- }
- );
+ appEvents.on('playlist-started', () => {
+ elem.toggleClass('view-mode--playlist', true);
+ });
+
+ appEvents.on('playlist-stopped', () => {
+ elem.toggleClass('view-mode--playlist', false);
+ });
// check if we are in server side render
if (document.cookie.indexOf('renderKey') !== -1) {
@@ -165,6 +166,8 @@ export function grafanaAppDirective(playlistSrv, contextSrv, $timeout, $rootScop
for (const drop of Drop.drops) {
drop.destroy();
}
+
+ appEvents.emit('hide-dash-search');
});
// handle kiosk mode
@@ -262,10 +265,6 @@ export function grafanaAppDirective(playlistSrv, contextSrv, $timeout, $rootScop
}, 100);
}
- if (target.parents('.navbar-buttons--playlist').length === 0) {
- playlistSrv.stop();
- }
-
// hide search
if (body.find('.search-container').length > 0) {
if (target.parents('.search-results-container, .search-field-wrapper').length === 0) {
@@ -280,6 +279,28 @@ export function grafanaAppDirective(playlistSrv, contextSrv, $timeout, $rootScop
if (popover.length > 0 && target.parents('.graph-legend').length === 0) {
popover.hide();
}
+
+ // hide time picker
+ const timePickerDropDownIsOpen = elem.find('.gf-timepicker-dropdown').length > 0;
+ if (timePickerDropDownIsOpen) {
+ const targetIsInTimePickerDropDown = target.parents('.gf-timepicker-dropdown').length > 0;
+ const targetIsInTimePickerNav = target.parents('.gf-timepicker-nav').length > 0;
+ const targetIsDatePickerRowBtn = target.parents('td[id^="datepicker-"]').length > 0;
+ const targetIsDatePickerHeaderBtn = target.parents('button[id^="datepicker-"]').length > 0;
+
+ if (
+ targetIsInTimePickerNav ||
+ targetIsInTimePickerDropDown ||
+ targetIsDatePickerRowBtn ||
+ targetIsDatePickerHeaderBtn
+ ) {
+ return;
+ }
+
+ scope.$apply(() => {
+ scope.appEvent('closeTimepicker');
+ });
+ }
});
},
};
diff --git a/public/app/routes/ReactContainer.tsx b/public/app/routes/ReactContainer.tsx
index 2cad3d828bf..35c23239561 100644
--- a/public/app/routes/ReactContainer.tsx
+++ b/public/app/routes/ReactContainer.tsx
@@ -5,6 +5,7 @@ import { Provider } from 'react-redux';
import coreModule from 'app/core/core_module';
import { store } from 'app/store/store';
import { ContextSrv } from 'app/core/services/context_srv';
+import { provideTheme } from 'app/core/utils/ConfigProvider';
function WrapInProvider(store, Component, props) {
return (
@@ -44,11 +45,15 @@ export function reactContainer(
$injector: $injector,
$rootScope: $rootScope,
$scope: scope,
+ routeInfo: $route.current.$$route.routeInfo,
};
- ReactDOM.render(WrapInProvider(store, component, props), elem[0]);
+ document.body.classList.add('is-react');
+
+ ReactDOM.render(WrapInProvider(store, provideTheme(component), props), elem[0]);
scope.$on('$destroy', () => {
+ document.body.classList.remove('is-react');
ReactDOM.unmountComponentAtNode(elem[0]);
});
},
diff --git a/public/app/routes/routes.ts b/public/app/routes/routes.ts
index 0f4c09a9c77..e0029cf2464 100644
--- a/public/app/routes/routes.ts
+++ b/public/app/routes/routes.ts
@@ -2,6 +2,7 @@ import './dashboard_loaders';
import './ReactContainer';
import { applyRouteRegistrationHandlers } from './registry';
+// Pages
import ServerStats from 'app/features/admin/ServerStats';
import AlertRuleList from 'app/features/alerting/AlertRuleList';
import TeamPages from 'app/features/teams/TeamPages';
@@ -20,40 +21,66 @@ import DataSourceDashboards from 'app/features/datasources/DataSourceDashboards'
import DataSourceSettingsPage from '../features/datasources/settings/DataSourceSettingsPage';
import OrgDetailsPage from '../features/org/OrgDetailsPage';
import SoloPanelPage from '../features/dashboard/containers/SoloPanelPage';
+import DashboardPage from '../features/dashboard/containers/DashboardPage';
import config from 'app/core/config';
+// Types
+import { DashboardRouteInfo } from 'app/types';
+
/** @ngInject */
export function setupAngularRoutes($routeProvider, $locationProvider) {
$locationProvider.html5Mode(true);
$routeProvider
.when('/', {
- templateUrl: 'public/app/partials/dashboard.html',
- controller: 'LoadDashboardCtrl',
- reloadOnSearch: false,
+ template: ' ',
pageClass: 'page-dashboard',
+ routeInfo: DashboardRouteInfo.Home,
+ reloadOnSearch: false,
+ resolve: {
+ component: () => DashboardPage,
+ },
})
.when('/d/:uid/:slug', {
- templateUrl: 'public/app/partials/dashboard.html',
- controller: 'LoadDashboardCtrl',
- reloadOnSearch: false,
+ template: ' ',
pageClass: 'page-dashboard',
+ routeInfo: DashboardRouteInfo.Normal,
+ reloadOnSearch: false,
+ resolve: {
+ component: () => DashboardPage,
+ },
})
.when('/d/:uid', {
- templateUrl: 'public/app/partials/dashboard.html',
- controller: 'LoadDashboardCtrl',
- reloadOnSearch: false,
+ template: ' ',
pageClass: 'page-dashboard',
+ reloadOnSearch: false,
+ routeInfo: DashboardRouteInfo.Normal,
+ resolve: {
+ component: () => DashboardPage,
+ },
})
.when('/dashboard/:type/:slug', {
- templateUrl: 'public/app/partials/dashboard.html',
- controller: 'LoadDashboardCtrl',
- reloadOnSearch: false,
+ template: ' ',
pageClass: 'page-dashboard',
+ routeInfo: DashboardRouteInfo.Normal,
+ reloadOnSearch: false,
+ resolve: {
+ component: () => DashboardPage,
+ },
+ })
+ .when('/dashboard/new', {
+ template: ' ',
+ pageClass: 'page-dashboard',
+ routeInfo: DashboardRouteInfo.New,
+ reloadOnSearch: false,
+ resolve: {
+ component: () => DashboardPage,
+ },
})
.when('/d-solo/:uid/:slug', {
template: ' ',
pageClass: 'dashboard-solo',
+ routeInfo: DashboardRouteInfo.Normal,
resolve: {
component: () => SoloPanelPage,
},
@@ -61,16 +88,11 @@ export function setupAngularRoutes($routeProvider, $locationProvider) {
.when('/dashboard-solo/:type/:slug', {
template: ' ',
pageClass: 'dashboard-solo',
+ routeInfo: DashboardRouteInfo.Normal,
resolve: {
component: () => SoloPanelPage,
},
})
- .when('/dashboard/new', {
- templateUrl: 'public/app/partials/dashboard.html',
- controller: 'NewDashboardCtrl',
- reloadOnSearch: false,
- pageClass: 'page-dashboard',
- })
.when('/dashboard/import', {
templateUrl: 'public/app/features/manage-dashboards/partials/dashboard_import.html',
controller: DashboardImportCtrl,
diff --git a/public/app/store/configureStore.ts b/public/app/store/configureStore.ts
index 570a387cd74..e2c33523271 100644
--- a/public/app/store/configureStore.ts
+++ b/public/app/store/configureStore.ts
@@ -11,6 +11,7 @@ import exploreReducers from 'app/features/explore/state/reducers';
import pluginReducers from 'app/features/plugins/state/reducers';
import dataSourcesReducers from 'app/features/datasources/state/reducers';
import usersReducers from 'app/features/users/state/reducers';
+import userReducers from 'app/features/profile/state/reducers';
import organizationReducers from 'app/features/org/state/reducers';
import { setStore } from './store';
@@ -25,6 +26,7 @@ const rootReducers = {
...pluginReducers,
...dataSourcesReducers,
...usersReducers,
+ ...userReducers,
...organizationReducers,
};
diff --git a/public/app/types/dashboard.ts b/public/app/types/dashboard.ts
index d33405c985e..d81f9e2bcba 100644
--- a/public/app/types/dashboard.ts
+++ b/public/app/types/dashboard.ts
@@ -1,5 +1,71 @@
import { DashboardAcl } from './acl';
-export interface DashboardState {
- permissions: DashboardAcl[];
+export interface MutableDashboard {
+ title: string;
+ meta: DashboardMeta;
+ destroy: () => void;
+}
+
+export interface DashboardDTO {
+ redirectUri?: string;
+ dashboard: DashboardDataDTO;
+ meta: DashboardMeta;
+}
+
+export interface DashboardMeta {
+ canSave?: boolean;
+ canEdit?: boolean;
+ canShare?: boolean;
+ canStar?: boolean;
+ canAdmin?: boolean;
+ url?: string;
+ folderId?: number;
+ fullscreen?: boolean;
+ isEditing?: boolean;
+ canMakeEditable?: boolean;
+ submenuEnabled?: boolean;
+ provisioned?: boolean;
+ focusPanelId?: boolean;
+ isStarred?: boolean;
+ showSettings?: boolean;
+ expires?: string;
+ isSnapshot?: boolean;
+ folderTitle?: string;
+ folderUrl?: string;
+ created?: string;
+}
+
+export interface DashboardDataDTO {
+ title: string;
+}
+
+export enum DashboardRouteInfo {
+ Home = 'home-dashboard',
+ New = 'new-dashboard',
+ Normal = 'normal-dashboard',
+ Scripted = 'scripted-dashboard',
+}
+
+export enum DashboardInitPhase {
+ NotStarted = 'Not started',
+ Fetching = 'Fetching',
+ Services = 'Services',
+ Failed = 'Failed',
+ Completed = 'Completed',
+}
+
+export interface DashboardInitError {
+ message: string;
+ error: any;
+}
+
+export const KIOSK_MODE_TV = 'tv';
+export type KioskUrlValue = 'tv' | '1' | true;
+
+export interface DashboardState {
+ model: MutableDashboard | null;
+ initPhase: DashboardInitPhase;
+ isInitSlow: boolean;
+ initError?: DashboardInitError;
+ permissions: DashboardAcl[] | null;
}
diff --git a/public/app/types/location.ts b/public/app/types/location.ts
index 7dcf57f7e02..a47ef05d2be 100644
--- a/public/app/types/location.ts
+++ b/public/app/types/location.ts
@@ -3,6 +3,10 @@ export interface LocationUpdate {
query?: UrlQueryMap;
routeParams?: UrlQueryMap;
partial?: boolean;
+ /*
+ * If true this will replace url state (ie cause no new browser history)
+ */
+ replace?: boolean;
}
export interface LocationState {
@@ -10,6 +14,7 @@ export interface LocationState {
path: string;
query: UrlQueryMap;
routeParams: UrlQueryMap;
+ replace: boolean;
}
export type UrlQueryValue = string | number | boolean | string[] | number[] | boolean[];
diff --git a/public/app/types/store.ts b/public/app/types/store.ts
index 78832052e96..72a8cf066f1 100644
--- a/public/app/types/store.ts
+++ b/public/app/types/store.ts
@@ -1,3 +1,6 @@
+import { ThunkAction, ThunkDispatch as GenericThunkDispatch } from 'redux-thunk';
+import { ActionOf } from 'app/core/redux';
+
import { NavIndex } from './navModel';
import { LocationState } from './location';
import { AlertRulesState } from './alerting';
@@ -27,3 +30,10 @@ export interface StoreState {
user: UserState;
plugins: PluginsState;
}
+
+/*
+ * Utility type to get strongly types thunks
+ */
+export type ThunkResult = ThunkAction>;
+
+export type ThunkDispatch = GenericThunkDispatch;
diff --git a/public/app/types/user.ts b/public/app/types/user.ts
index 37c80074dca..7691558ce90 100644
--- a/public/app/types/user.ts
+++ b/public/app/types/user.ts
@@ -1,5 +1,3 @@
-import { DashboardSearchHit } from './search';
-
export interface OrgUser {
avatarUrl: string;
email: string;
@@ -47,5 +45,5 @@ export interface UsersState {
}
export interface UserState {
- starredDashboards: DashboardSearchHit[];
+ orgId: number;
}
diff --git a/public/img/icons_dark_theme/icon_advanced.svg b/public/img/icons_dark_theme/icon_advanced.svg
index 5fd18a86dd5..dea3ddff685 100644
--- a/public/img/icons_dark_theme/icon_advanced.svg
+++ b/public/img/icons_dark_theme/icon_advanced.svg
@@ -4,7 +4,7 @@
diff --git a/public/img/icons_dark_theme/icon_advanced_active.svg b/public/img/icons_dark_theme/icon_advanced_active.svg
index 80672a2595b..1227ddc868c 100644
--- a/public/img/icons_dark_theme/icon_advanced_active.svg
+++ b/public/img/icons_dark_theme/icon_advanced_active.svg
@@ -5,7 +5,7 @@
width="121px" height="100px" viewBox="0 0 121 100" style="enable-background:new 0 0 121 100;" xml:space="preserve">
diff --git a/public/img/icons_dark_theme/icon_alerting_active.svg b/public/img/icons_dark_theme/icon_alerting_active.svg
index 5c70638f64c..4966761ba28 100644
--- a/public/img/icons_dark_theme/icon_alerting_active.svg
+++ b/public/img/icons_dark_theme/icon_alerting_active.svg
@@ -5,7 +5,7 @@
width="121px" height="100px" viewBox="0 0 121 100" style="enable-background:new 0 0 121 100;" xml:space="preserve">
diff --git a/public/img/icons_dark_theme/icon_query.svg b/public/img/icons_dark_theme/icon_query.svg
index fab09779936..7b9dce299b5 100644
--- a/public/img/icons_dark_theme/icon_query.svg
+++ b/public/img/icons_dark_theme/icon_query.svg
@@ -4,7 +4,7 @@
diff --git a/public/img/icons_dark_theme/icon_query_active.svg b/public/img/icons_dark_theme/icon_query_active.svg
index ed8141b51bf..4831d9c52c8 100644
--- a/public/img/icons_dark_theme/icon_query_active.svg
+++ b/public/img/icons_dark_theme/icon_query_active.svg
@@ -5,7 +5,7 @@
width="121px" height="100px" viewBox="0 0 121 100" style="enable-background:new 0 0 121 100;" xml:space="preserve">
diff --git a/public/img/icons_dark_theme/icon_visualize_active.svg b/public/img/icons_dark_theme/icon_visualize_active.svg
index 43207ebaae4..31b5938652e 100644
--- a/public/img/icons_dark_theme/icon_visualize_active.svg
+++ b/public/img/icons_dark_theme/icon_visualize_active.svg
@@ -5,7 +5,7 @@
width="121px" height="100px" viewBox="0 0 121 100" style="enable-background:new 0 0 121 100;" xml:space="preserve">
diff --git a/public/sass/_variables.dark.scss b/public/sass/_variables.dark.scss
index 66943bb733a..07f65bb5b37 100644
--- a/public/sass/_variables.dark.scss
+++ b/public/sass/_variables.dark.scss
@@ -16,9 +16,6 @@ $forest-base: #37872D;
$forest-shade: #19730E;
// Grays
-// -------------------------
-$black: #000;
-
// -------------------------
$black: #000;
$dark-1: #141414;
@@ -31,7 +28,6 @@ $gray-2: #8e8e8e;
$gray-3: #b3b3b3;
$gray-4: #d8d9da;
$gray-5: #ececec;
-$gray-6: #f4f5f8;
$gray-7: #fbfbfb;
$gray-blue: #212327;
@@ -79,6 +75,7 @@ $text-color-faint: $dark-5;
$text-color-emphasis: $gray-5;
$text-shadow-faint: 1px 1px 4px rgb(45, 45, 45);
+$textShadow: none;
// gradients
$brand-gradient: linear-gradient(
@@ -122,8 +119,8 @@ $divider-border-color: $gray-1;
// Graphite Target Editor
$tight-form-bg: $dark-3;
-$tight-form-func-bg: #333334;
-$tight-form-func-highlight-bg: #444445;
+$tight-form-func-bg: $dark-4;
+$tight-form-func-highlight-bg: $dark-5;
$modal-backdrop-bg: #353c42;
$code-tag-bg: $dark-1;
@@ -145,14 +142,12 @@ $empty-list-cta-bg: $gray-blue;
// Scrollbars
$scrollbarBackground: #404357;
$scrollbarBackground2: #3a3a3a;
-
$scrollbarBorder: black;
// Tables
// -------------------------
$table-bg: transparent; // overall background-color
$table-bg-accent: $dark-3; // for striping
-$table-bg-hover: $dark-4; // for hover
$table-border: $dark-3; // table and cell border
$table-bg-odd: $dark-2;
@@ -161,8 +156,11 @@ $table-bg-hover: $dark-3;
// Buttons
// -------------------------
-$btn-primary-bg: $sapphire-base;
-$btn-primary-bg-hl: $sapphire-shade;
+$btn-primary-bg: #ff6600;
+$btn-primary-bg-hl: #bc3e06;
+
+$btn-secondary-bg-hl: lighten($blue-dark, 5%);
+$btn-secondary-bg: $blue-dark;
$btn-secondary-bg: $sapphire-base;
$btn-secondary-bg-hl: $sapphire-shade;
@@ -199,6 +197,9 @@ $input-label-bg: $gray-blue;
$input-label-border-color: $dark-3;
$input-color-select-arrow: $white;
+// Input placeholder text color
+$placeholderText: darken($text-color, 25%);
+
// Search
$search-shadow: 0 0 30px 0 $black;
$search-filter-box-bg: $gray-blue;
@@ -214,28 +215,19 @@ $dropdownBackground: $dark-3;
$dropdownBorder: rgba(0, 0, 0, 0.2);
$dropdownDividerTop: transparent;
$dropdownDividerBottom: #444;
-$dropdownDivider: $dropdownDividerBottom;
$dropdownLinkColor: $text-color;
$dropdownLinkColorHover: $white;
$dropdownLinkColorActive: $white;
-$dropdownLinkBackgroundActive: $dark-4;
$dropdownLinkBackgroundHover: $dark-4;
-// COMPONENT VARIABLES
-// --------------------------------------------------
-
-// -------------------------
-$placeholderText: darken($text-color, 25%);
-
// Horizontal forms & lists
// -------------------------
$horizontalComponentOffset: 180px;
-// Wells
+// Navbar
// -------------------------
-
$navbarHeight: 55px;
$navbarBackground: $panel-bg;
@@ -263,9 +255,6 @@ $menu-dropdown-bg: $body-bg;
$menu-dropdown-hover-bg: $dark-2;
$menu-dropdown-shadow: 5px 5px 20px -5px $black;
-// Breadcrumb
-// -------------------------
-
// Tabs
// -------------------------
$tab-border-color: $dark-4;
@@ -273,9 +262,6 @@ $tab-border-color: $dark-4;
// Toolbar
$toolbar-bg: $input-black;
-// Pagination
-// -------------------------
-
// Form states and alerts
// -------------------------
$warning-text-color: $warn;
@@ -310,7 +296,6 @@ $tooltipBackground: $black;
$tooltipColor: $gray-4;
$tooltipArrowColor: $tooltipBackground;
$tooltipBackgroundError: $brand-danger;
-$tooltipBackgroundBrand: $brand-primary;
// images
$checkboxImageUrl: '../img/checkbox.png';
@@ -379,17 +364,15 @@ $checkbox-color: $dark-1;
//Panel Edit
// -------------------------
$panel-editor-shadow: 0 0 20px black;
-$panel-editor-border: 1px solid $dark-3;
$panel-editor-side-menu-shadow: drop-shadow(0 0 10px $black);
-$panel-editor-toolbar-view-bg: $input-black;
$panel-editor-viz-item-shadow: 0 0 8px $dark-5;
$panel-editor-viz-item-border: 1px solid $dark-5;
$panel-editor-viz-item-shadow-hover: 0 0 4px $sapphire-shade;
$panel-editor-viz-item-border-hover: 1px solid $sapphire-shade;
$panel-editor-viz-item-bg: $input-black;
$panel-editor-tabs-line-color: #e3e3e3;
-$panel-editor-viz-item-bg-hover: $sapphire-faint;
-$panel-editor-viz-item-bg-hover-active: darken($orange, 45%);
+
+$panel-editor-viz-item-bg-hover: darken($blue, 47%);
$panel-options-group-border: none;
$panel-options-group-header-bg: $gray-blue;
diff --git a/public/sass/_variables.light.scss b/public/sass/_variables.light.scss
index 85cb047be25..4298c50369f 100644
--- a/public/sass/_variables.light.scss
+++ b/public/sass/_variables.light.scss
@@ -1,7 +1,3 @@
-// Cosmo 2.3.2
-// Variables
-// --------------------------------------------------
-
// Global values
// --------------------------------------------------
@@ -23,10 +19,6 @@ $yellow-base: #F2CC0C;
// Grays
// -------------------------
$black: #000;
-
-// -------------------------
-$black: #000;
-$dark-1: #13161d;
$dark-2: #1e2028;
$dark-3: #303133;
$dark-4: #35373f;
@@ -44,6 +36,7 @@ $white: #fff;
// Accent colors
// -------------------------
$blue: #0083b3;
+$blue-light: #00a8e6;
$green: #3aa655;
$red: $lobster-base;
$yellow: #ff851b;
@@ -70,20 +63,26 @@ $critical: $lobster-shade;
// Scaffolding
// -------------------------
-
$body-bg: $gray-7;
$page-bg: $gray-7;
+
$body-color: $gray-1;
$text-color: $gray-1;
$text-color-strong: $dark-2;
-$text-color-weak: $gray-2;
+$text-color-weak: $gray-3;
$text-color-faint: $gray-4;
$text-color-emphasis: $dark-5;
$text-shadow-faint: none;
// gradients
-$brand-gradient: linear-gradient(to right, rgba(255, 213, 0, 1) 0%, rgba(255, 68, 0, 1) 99%, rgba(255, 68, 0, 1) 100%);
+$brand-gradient: linear-gradient(
+ to right,
+ rgba(255, 213, 0, 1) 0%,
+ rgba(255, 68, 0, 1) 99%,
+ rgba(255, 68, 0, 1) 100%
+);
+
$page-gradient: linear-gradient(180deg, $white 10px, $gray-7 100px);
$edit-gradient: linear-gradient(-60deg, $gray-7, #f5f6f9 70%, $gray-7 98%);
@@ -104,7 +103,6 @@ $hr-border-color: $dark-3 !default;
// Panel
// -------------------------
-
$panel-bg: $white;
$panel-border: solid 1px $gray-5;
$panel-header-hover-bg: $gray-6;
@@ -119,7 +117,6 @@ $divider-border-color: $gray-2;
// Graphite Target Editor
$tight-form-bg: #eaebee;
-
$tight-form-func-bg: $gray-5;
$tight-form-func-highlight-bg: $gray-6;
@@ -137,24 +134,23 @@ $list-item-bg: linear-gradient(135deg, $gray-5, $gray-6); //$card-background;
$list-item-hover-bg: darken($gray-5, 5%);
$list-item-link-color: $text-color;
$list-item-shadow: $card-shadow;
+
$empty-list-cta-bg: $gray-6;
-// Tables
-// -------------------------
-$table-bg: transparent; // overall background-color
-$table-bg-accent: $gray-5; // for striping
-$table-bg-hover: $gray-5; // for hover
-$table-bg-active: $table-bg-hover !default;
-$table-border: $gray-3; // table and cell border
-
-$table-bg-odd: $gray-6;
-$table-bg-hover: $gray-5;
-
// Scrollbars
$scrollbarBackground: $gray-5;
$scrollbarBackground2: $gray-5;
$scrollbarBorder: $gray-4;
+// Tables
+// -------------------------
+$table-bg: transparent; // overall background-color
+$table-bg-accent: $gray-5; // for striping
+$table-border: $gray-3; // table and cell border
+
+$table-bg-odd: $gray-6;
+$table-bg-hover: $gray-5;
+
// Buttons
// -------------------------
$btn-primary-bg: $sapphire-base;
@@ -173,11 +169,12 @@ $btn-inverse-text-shadow: 0 1px 0 rgba(255, 255, 255, 0.4);
$btn-link-color: $gray-1;
+$iconContainerBackground: $white;
+
$btn-divider-left: $gray-4;
$btn-divider-right: $gray-7;
-$btn-drag-image: '../img/grab_light.svg';
-$iconContainerBackground: $white;
+$btn-drag-image: '../img/grab_light.svg';
// Forms
// -------------------------
@@ -194,29 +191,8 @@ $input-label-bg: $gray-5;
$input-label-border-color: $gray-5;
$input-color-select-arrow: $gray-1;
-// Sidemenu
-// -------------------------
-$side-menu-bg: $dark-2;
-$side-menu-bg-mobile: rgba(0, 0, 0, 0); //$gray-6;
-$side-menu-item-hover-bg: $gray-1;
-$side-menu-shadow: 5px 0px 10px -5px $gray-1;
-$side-menu-link-color: $gray-6;
-
-// Menu dropdowns
-// -------------------------
-$menu-dropdown-bg: $gray-7;
-$menu-dropdown-hover-bg: $gray-6;
-$menu-dropdown-shadow: 5px 5px 10px -5px $gray-1;
-
-// Breadcrumb
-// -------------------------
-
-// Tabs
-// -------------------------
-$tab-border-color: $gray-5;
-
-// Toolbar
-$toolbar-bg: white;
+// Input placeholder text color
+$placeholderText: $gray-2;
// search
$search-shadow: 0 5px 30px 0 $gray-4;
@@ -233,52 +209,52 @@ $dropdownBackground: $white;
$dropdownBorder: $gray-4;
$dropdownDividerTop: $gray-6;
$dropdownDividerBottom: $white;
-$dropdownDivider: $dropdownDividerTop;
$dropdownLinkColor: $dark-3;
$dropdownLinkColorHover: $link-color;
$dropdownLinkColorActive: $link-color;
-$dropdownLinkBackgroundActive: $gray-6;
$dropdownLinkBackgroundHover: $gray-6;
-// COMPONENT VARIABLES
-// --------------------------------------------------
-
-// Input placeholder text color
-// -------------------------
-$placeholderText: $gray-2;
-
-// Hr border color
-// -------------------------
-$hrBorder: $gray-3;
-
// Horizontal forms & lists
// -------------------------
$horizontalComponentOffset: 180px;
-// Wells
-// -------------------------
-
// Navbar
// -------------------------
-
$navbarHeight: 52px;
+
$navbarBackground: $white;
$navbarBorder: 1px solid $gray-4;
$navbarShadow: 0 0 3px #c1c1c1;
$navbarLinkColor: #444;
-$navbarBrandColor: $navbarLinkColor;
-
$navbarButtonBackground: lighten($navbarBackground, 3%);
$navbarButtonBackgroundHighlight: lighten($navbarBackground, 5%);
$navbar-button-border: $gray-4;
-// Pagination
+// Sidemenu
// -------------------------
+$side-menu-bg: $dark-2;
+$side-menu-bg-mobile: rgba(0, 0, 0, 0); //$gray-6;
+$side-menu-item-hover-bg: $gray-1;
+$side-menu-shadow: 5px 0px 10px -5px $gray-1;
+$side-menu-link-color: $gray-6;
+
+// Menu dropdowns
+// -------------------------
+$menu-dropdown-bg: $gray-7;
+$menu-dropdown-hover-bg: $gray-6;
+$menu-dropdown-shadow: 5px 5px 10px -5px $gray-1;
+
+// Tabs
+// -------------------------
+$tab-border-color: $gray-5;
+
+// Toolbar
+$toolbar-bg: white;
// Form states and alerts
// -------------------------
@@ -300,6 +276,7 @@ $popover-shadow: 0 0 20px $white;
$popover-help-bg: $sapphire-base;
$popover-help-color: $gray-6;
+
$popover-error-bg: $btn-danger-bg;
// Tooltips and popovers
@@ -313,7 +290,6 @@ $tooltipBackground: $gray-1;
$tooltipColor: $gray-7;
$tooltipArrowColor: $tooltipBackground; // Used by Angular tooltip
$tooltipBackgroundError: $brand-danger;
-$tooltipBackgroundBrand: $brand-primary;
// images
$checkboxImageUrl: '../img/checkbox_white.png';
@@ -325,8 +301,6 @@ $info-box-border-color: $sapphire-base;
$footer-link-color: $gray-3;
$footer-link-hover: $dark-5;
-// collapse box
-
// json explorer
$json-explorer-default-color: black;
$json-explorer-string-color: green;
@@ -346,9 +320,6 @@ $json-explorer-url-color: $sapphire-base;
$diff-label-bg: $gray-5;
$diff-label-fg: $gray-2;
-$diff-switch-bg: $gray-7;
-$diff-switch-disabled: $gray-5;
-
$diff-arrow-color: $dark-3;
$diff-group-bg: $gray-7;
@@ -363,6 +334,7 @@ $diff-json-new: #664e33;
$diff-json-changed-fg: $gray-7;
$diff-json-changed-num: $gray-4;
+
$diff-json-icon: $gray-4;
//Submenu
@@ -386,17 +358,15 @@ $checkbox-color: $gray-7;
//Panel Edit
// -------------------------
$panel-editor-shadow: 0px 0px 8px $gray-3;
-$panel-editor-border: 1px solid $dark-4;
$panel-editor-side-menu-shadow: drop-shadow(0 0 2px $gray-3);
-$panel-editor-toolbar-view-bg: $white;
$panel-editor-viz-item-shadow: 0 0 4px $gray-3;
$panel-editor-viz-item-border: 1px solid $gray-3;
$panel-editor-viz-item-shadow-hover: 0 0 4px $sapphire-light;
$panel-editor-viz-item-border-hover: 1px solid $sapphire-light;
$panel-editor-viz-item-bg: $white;
$panel-editor-tabs-line-color: $dark-5;
-$panel-editor-viz-item-bg-hover: $sapphire-faint;
-$panel-editor-viz-item-bg-hover-active: lighten($orange, 34%);
+$panel-editor-viz-item-bg-hover: lighten($blue, 62%);
+
$panel-options-group-border: none;
$panel-options-group-header-bg: $gray-5;
diff --git a/public/sass/base/_icons.scss b/public/sass/base/_icons.scss
index a60259ac0f2..a2649b31fcd 100644
--- a/public/sass/base/_icons.scss
+++ b/public/sass/base/_icons.scss
@@ -212,7 +212,7 @@
padding-right: 5px;
}
-.panel-editor-tabs {
+.panel-editor-tabs, .add-panel-widget__icon {
.gicon-advanced-active {
background-image: url('../img/icons_#{$theme-name}_theme/icon_advanced_active.svg');
}
diff --git a/public/sass/components/_dashboard_settings.scss b/public/sass/components/_dashboard_settings.scss
index 5e17e025196..38883b7c80e 100644
--- a/public/sass/components/_dashboard_settings.scss
+++ b/public/sass/components/_dashboard_settings.scss
@@ -16,6 +16,9 @@
opacity: 1;
transition: opacity 300ms ease-in-out;
}
+ .dashboard-container {
+ display: none;
+ }
}
.dashboard-settings__content {
diff --git a/public/sass/components/_navbar.scss b/public/sass/components/_navbar.scss
index 088dd72f37b..5a5b177e0dd 100644
--- a/public/sass/components/_navbar.scss
+++ b/public/sass/components/_navbar.scss
@@ -83,8 +83,7 @@
font-size: 19px;
line-height: 8px;
opacity: 0.75;
- margin-right: 8px;
- // icon hidden on smaller screens
+ margin-right: 13px;
display: none;
}
@@ -102,7 +101,7 @@
display: flex;
align-items: center;
justify-content: flex-end;
- margin-right: $spacer;
+ margin-left: 10px;
&--close {
display: none;
diff --git a/public/sass/components/_panel_editor.scss b/public/sass/components/_panel_editor.scss
index b791231a242..1de136c09f1 100644
--- a/public/sass/components/_panel_editor.scss
+++ b/public/sass/components/_panel_editor.scss
@@ -146,15 +146,17 @@
padding-bottom: 6px;
transition: transform 1 ease;
- &--current {
- box-shadow: 0 0 6px $orange;
- border: 1px solid $orange;
- }
-
&:hover {
box-shadow: $panel-editor-viz-item-shadow-hover;
background: $panel-editor-viz-item-bg-hover;
border: $panel-editor-viz-item-border-hover;
+
+ }
+
+ &--current {
+ box-shadow: 0 0 6px $orange !important;
+ border: 1px solid $orange !important;
+ background: $panel-editor-viz-item-bg !important;
}
}
diff --git a/public/sass/pages/_dashboard.scss b/public/sass/pages/_dashboard.scss
index 9ca4e092f02..c02c9227d29 100644
--- a/public/sass/pages/_dashboard.scss
+++ b/public/sass/pages/_dashboard.scss
@@ -276,3 +276,19 @@ div.flot-text {
.panel-full-edit {
padding-top: $dashboard-padding;
}
+
+.dashboard-loading {
+ height: 60vh;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ .alert {
+ max-width: 600px;
+ min-width: 600px;
+ }
+}
+
+.dashboard-loading__text {
+ font-size: $font-size-lg;
+}
diff --git a/public/test/core/utils/version_test.ts b/public/test/core/utils/version.test.ts
similarity index 96%
rename from public/test/core/utils/version_test.ts
rename to public/test/core/utils/version.test.ts
index 91330389e24..47a31d99e69 100644
--- a/public/test/core/utils/version_test.ts
+++ b/public/test/core/utils/version.test.ts
@@ -44,6 +44,7 @@ describe('SemVersion', () => {
{ values: ['3.1.1-beta1', '3.1'], expected: true },
{ values: ['3.4.5', '4'], expected: false },
{ values: ['3.4.5', '3.5'], expected: false },
+ { values: ['6.0.0', '5.2.0'], expected: true },
];
cases.forEach(testCase => {
expect(isVersionGtOrEq(testCase.values[0], testCase.values[1])).toBe(testCase.expected);
diff --git a/public/test/specs/helpers.ts b/public/test/specs/helpers.ts
index 1570c7dd9b7..b8307186540 100644
--- a/public/test/specs/helpers.ts
+++ b/public/test/specs/helpers.ts
@@ -143,7 +143,7 @@ export function DashboardViewStateStub(this: any) {
}
export function TimeSrvStub(this: any) {
- this.init = sinon.spy();
+ this.init = () => {};
this.time = { from: 'now-1h', to: 'now' };
this.timeRange = function(parse) {
if (parse === false) {
diff --git a/public/views/index-template.html b/public/views/index-template.html
index a1c955d45d6..895b0e4ae19 100644
--- a/public/views/index-template.html
+++ b/public/views/index-template.html
@@ -189,10 +189,10 @@
-
+