diff --git a/packages/grafana-data/src/types/data.ts b/packages/grafana-data/src/types/data.ts index d7a39d1db21..9d05768b679 100644 --- a/packages/grafana-data/src/types/data.ts +++ b/packages/grafana-data/src/types/data.ts @@ -97,6 +97,9 @@ export interface AnnotationEvent { dashboardId?: number; panelId?: number; userId?: number; + login?: string; + email?: string; + avatarUrl?: string; time?: number; timeEnd?: number; isRegion?: boolean; diff --git a/public/app/core/components/TagFilter/TagFilter.tsx b/public/app/core/components/TagFilter/TagFilter.tsx index 89d3e8469b6..845b0838a4c 100644 --- a/public/app/core/components/TagFilter/TagFilter.tsx +++ b/public/app/core/components/TagFilter/TagFilter.tsx @@ -11,9 +11,14 @@ import { TagBadge } from './TagBadge'; import { NoOptionsMessage, IndicatorsContainer, resetSelectStyles } from '@grafana/ui'; import { escapeStringForRegex } from '../FilterInput/FilterInput'; +export interface TermCount { + term: string; + count: number; +} + export interface Props { tags: string[]; - tagOptions: () => any; + tagOptions: () => Promise; onChange: (tags: string[]) => void; } @@ -25,7 +30,7 @@ export class TagFilter extends React.Component { } onLoadOptions = (query: string) => { - return this.props.tagOptions().then((options: any[]) => { + return this.props.tagOptions().then(options => { return options.map(option => ({ value: option.term, label: option.term, diff --git a/public/app/features/plugins/built_in_plugins.ts b/public/app/features/plugins/built_in_plugins.ts index 4429085fcff..913f8e9a36f 100644 --- a/public/app/features/plugins/built_in_plugins.ts +++ b/public/app/features/plugins/built_in_plugins.ts @@ -22,6 +22,7 @@ import * as graphPanel from 'app/plugins/panel/graph/module'; import * as dashListPanel from 'app/plugins/panel/dashlist/module'; import * as pluginsListPanel from 'app/plugins/panel/pluginlist/module'; import * as alertListPanel from 'app/plugins/panel/alertlist/module'; +import * as annoListPanel from 'app/plugins/panel/annolist/module'; import * as heatmapPanel from 'app/plugins/panel/heatmap/module'; import * as tablePanel from 'app/plugins/panel/table/module'; import * as table2Panel from 'app/plugins/panel/table2/module'; @@ -59,6 +60,7 @@ const builtInPlugins = { 'app/plugins/panel/dashlist/module': dashListPanel, 'app/plugins/panel/pluginlist/module': pluginsListPanel, 'app/plugins/panel/alertlist/module': alertListPanel, + 'app/plugins/panel/annolist/module': annoListPanel, 'app/plugins/panel/heatmap/module': heatmapPanel, 'app/plugins/panel/table/module': tablePanel, 'app/plugins/panel/table2/module': table2Panel, diff --git a/public/app/plugins/panel/annolist/AnnoListEditor.tsx b/public/app/plugins/panel/annolist/AnnoListEditor.tsx new file mode 100644 index 00000000000..4213f24ad1f --- /dev/null +++ b/public/app/plugins/panel/annolist/AnnoListEditor.tsx @@ -0,0 +1,194 @@ +// Libraries +import React, { PureComponent, ChangeEvent } from 'react'; + +// Components +import { PanelEditorProps, PanelOptionsGroup, PanelOptionsGrid, Switch, FormField, FormLabel } from '@grafana/ui'; + +import { toIntegerOrUndefined, toNumberString } from '@grafana/data'; + +// Types +import { AnnoOptions } from './types'; +import { TagBadge } from 'app/core/components/TagFilter/TagBadge'; + +interface State { + tag: string; +} + +export class AnnoListEditor extends PureComponent, State> { + constructor(props: PanelEditorProps) { + super(props); + + this.state = { + tag: '', + }; + } + + // Display + //----------- + + onToggleShowUser = () => + this.props.onOptionsChange({ ...this.props.options, showUser: !this.props.options.showUser }); + + onToggleShowTime = () => + this.props.onOptionsChange({ ...this.props.options, showTime: !this.props.options.showTime }); + + onToggleShowTags = () => + this.props.onOptionsChange({ ...this.props.options, showTags: !this.props.options.showTags }); + + // Navigate + //----------- + + onNavigateBeforeChange = (event: ChangeEvent) => { + this.props.onOptionsChange({ ...this.props.options, navigateBefore: event.target.value }); + }; + + onNavigateAfterChange = (event: ChangeEvent) => { + this.props.onOptionsChange({ ...this.props.options, navigateAfter: event.target.value }); + }; + + onToggleNavigateToPanel = () => + this.props.onOptionsChange({ ...this.props.options, navigateToPanel: !this.props.options.navigateToPanel }); + + // Search + //----------- + onLimitChange = (event: ChangeEvent) => { + const v = toIntegerOrUndefined(event.target.value); + this.props.onOptionsChange({ ...this.props.options, limit: v }); + }; + + onToggleOnlyFromThisDashboard = () => + this.props.onOptionsChange({ + ...this.props.options, + onlyFromThisDashboard: !this.props.options.onlyFromThisDashboard, + }); + + onToggleOnlyInTimeRange = () => + this.props.onOptionsChange({ ...this.props.options, onlyInTimeRange: !this.props.options.onlyInTimeRange }); + + // Tags + //----------- + + onTagTextChange = (event: ChangeEvent) => { + this.setState({ tag: event.target.value }); + }; + + onTagClick = (e: React.SyntheticEvent, tag: string) => { + e.stopPropagation(); + + const tags = this.props.options.tags.filter(item => item !== tag); + this.props.onOptionsChange({ + ...this.props.options, + tags, + }); + }; + + renderTags = (tags: string[]): JSX.Element => { + if (!tags || !tags.length) { + return null; + } + return ( + <> + {tags.map(tag => { + return ( + this.onTagClick(e, tag)} className="pointer"> + + + ); + })} + + ); + }; + + render() { + const { options } = this.props; + const labelWidth = 8; + + return ( + + + + + + + + + + + + + + +
+ Tags + {this.renderTags(options.tags)} + { + if (this.state.tag && ev.key === 'Enter') { + const tags = [...options.tags, this.state.tag]; + this.props.onOptionsChange({ + ...this.props.options, + tags, + }); + this.setState({ tag: '' }); + ev.preventDefault(); + } + }} + /> +
+ + +
+
+ ); + } +} diff --git a/public/app/plugins/panel/annolist/AnnoListPanel.tsx b/public/app/plugins/panel/annolist/AnnoListPanel.tsx new file mode 100644 index 00000000000..b98645ddba0 --- /dev/null +++ b/public/app/plugins/panel/annolist/AnnoListPanel.tsx @@ -0,0 +1,304 @@ +// Libraries +import React, { PureComponent } from 'react'; + +// Types +import { AnnoOptions } from './types'; +import { dateTime, DurationUnit, AnnotationEvent } from '@grafana/data'; +import { PanelProps, Tooltip } from '@grafana/ui'; +import { getBackendSrv } from 'app/core/services/backend_srv'; +import { AbstractList } from '@grafana/ui/src/components/List/AbstractList'; +import { TagBadge } from 'app/core/components/TagFilter/TagBadge'; +import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; +import appEvents from 'app/core/app_events'; + +import { updateLocation } from 'app/core/actions'; +import { store } from 'app/store/store'; +import { cx, css } from 'emotion'; + +interface UserInfo { + id: number; + login: string; + email: string; +} + +interface Props extends PanelProps {} +interface State { + annotations: AnnotationEvent[]; + timeInfo: string; + loaded: boolean; + queryUser?: UserInfo; + queryTags: string[]; +} + +export class AnnoListPanel extends PureComponent { + constructor(props: Props) { + super(props); + + this.state = { + annotations: [], + timeInfo: '', + loaded: false, + queryTags: [], + }; + } + + componentDidMount() { + this.doSearch(); + } + + componentDidUpdate(prevProps: Props, prevState: State) { + const { options, timeRange } = this.props; + const needsQuery = + options !== prevProps.options || + this.state.queryTags !== prevState.queryTags || + this.state.queryUser !== prevState.queryUser || + timeRange !== prevProps.timeRange; + + if (needsQuery) { + this.doSearch(); + } + } + + async doSearch() { + // http://docs.grafana.org/http_api/annotations/ + // https://github.com/grafana/grafana/blob/master/public/app/core/services/backend_srv.ts + // https://github.com/grafana/grafana/blob/master/public/app/features/annotations/annotations_srv.ts + + const { options } = this.props; + const { queryUser, queryTags } = this.state; + + const params: any = { + tags: options.tags, + limit: options.limit, + type: 'annotation', // Skip the Annotations that are really alerts. (Use the alerts panel!) + }; + + if (options.onlyFromThisDashboard) { + params.dashboardId = getDashboardSrv().getCurrent().id; + } + + let timeInfo = ''; + if (options.onlyInTimeRange) { + const { timeRange } = this.props; + params.from = timeRange.from.valueOf(); + params.to = timeRange.to.valueOf(); + } else { + timeInfo = 'All Time'; + } + + if (queryUser) { + params.userId = queryUser.id; + } + + if (options.tags && options.tags.length) { + params.tags = options.tags; + } + + if (queryTags.length) { + params.tags = params.tags ? [...params.tags, ...queryTags] : queryTags; + } + + const annotations = await getBackendSrv().get('/api/annotations', params); + this.setState({ + annotations, + timeInfo, + loaded: true, + }); + } + + onAnnoClick = (e: React.SyntheticEvent, anno: AnnotationEvent) => { + e.stopPropagation(); + const { options } = this.props; + const dashboardSrv = getDashboardSrv(); + const current = dashboardSrv.getCurrent(); + + const params: any = { + from: this._timeOffset(anno.time, options.navigateBefore, true), + to: this._timeOffset(anno.time, options.navigateAfter, false), + }; + + if (options.navigateToPanel) { + params.panelId = anno.panelId; + params.fullscreen = true; + } + + if (current.id === anno.dashboardId) { + store.dispatch( + updateLocation({ + query: params, + partial: true, + }) + ); + return; + } + + getBackendSrv() + .get('/api/search', { dashboardIds: anno.dashboardId }) + .then((res: any[]) => { + if (res && res.length && res[0].id === anno.dashboardId) { + const dash = res[0]; + store.dispatch( + updateLocation({ + query: params, + path: dash.url, + }) + ); + return; + } + appEvents.emit('alert-warning', ['Unknown Dashboard: ' + anno.dashboardId]); + }); + }; + + _timeOffset(time: number, offset: string, subtract = false): number { + let incr = 5; + let unit = 'm'; + const parts = /^(\d+)(\w)/.exec(offset); + if (parts && parts.length === 3) { + incr = parseInt(parts[1], 10); + unit = parts[2]; + } + + const t = dateTime(time); + if (subtract) { + incr *= -1; + } + return t.add(incr, unit as DurationUnit).valueOf(); + } + + onTagClick = (e: React.SyntheticEvent, tag: string, remove: boolean) => { + e.stopPropagation(); + const queryTags = remove ? this.state.queryTags.filter(item => item !== tag) : [...this.state.queryTags, tag]; + + this.setState({ queryTags }); + }; + + onUserClick = (e: React.SyntheticEvent, anno: AnnotationEvent) => { + e.stopPropagation(); + this.setState({ + queryUser: { + id: anno.userId, + login: anno.login, + email: anno.email, + }, + }); + }; + + onClearUser = () => { + this.setState({ + queryUser: undefined, + }); + }; + + renderTags = (tags: string[], remove: boolean): JSX.Element => { + if (!tags || !tags.length) { + return null; + } + return ( + <> + {tags.map(tag => { + return ( + this.onTagClick(e, tag, remove)} className="pointer"> + + + ); + })} + + ); + }; + + renderItem = (anno: AnnotationEvent, index: number): JSX.Element => { + const { options } = this.props; + const { showUser, showTags, showTime } = options; + const dashboard = getDashboardSrv().getCurrent(); + + return ( +
+ { + this.onAnnoClick(e, anno); + }} + > + + {anno.text} + + + + {anno.login && showUser && ( + + + Created by: +
{anno.email} +
+ } + theme="info" + placement="top" + > + this.onUserClick(e, anno)} className="graph-annotation__user"> + + + +
+ )} + {showTags && this.renderTags(anno.tags, false)} +
+ + {showTime && {dashboard.formatDate(anno.time)}} + +
+ ); + }; + + render() { + const { height } = this.props; + const { loaded, annotations, queryUser, queryTags } = this.state; + if (!loaded) { + return
loading...
; + } + + // Previously we showed inidication that it covered all time + // { timeInfo && ( + // + // {timeInfo} + // + // )} + + const hasFilter = queryUser || queryTags.length > 0; + + return ( +
+ {hasFilter && ( +
+ Filter:   + {queryUser && ( + + {queryUser.email} + + )} + {queryTags.length > 0 && this.renderTags(queryTags, true)} +
+ )} + + {annotations.length < 1 &&
No Annotations Found
} + + { + return item.id + ''; + }} + className="dashlist" + /> +
+ ); + } +} diff --git a/public/app/plugins/panel/annolist/README.md b/public/app/plugins/panel/annolist/README.md new file mode 100644 index 00000000000..ef0e0d123aa --- /dev/null +++ b/public/app/plugins/panel/annolist/README.md @@ -0,0 +1,4 @@ +# Annotation List Panel - Native Plugin + +This Annotations List panel is **included** with Grafana. + diff --git a/public/app/plugins/panel/annolist/img/icn-annolist-panel.svg b/public/app/plugins/panel/annolist/img/icn-annolist-panel.svg new file mode 100644 index 00000000000..f584770dff0 --- /dev/null +++ b/public/app/plugins/panel/annolist/img/icn-annolist-panel.svg @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/app/plugins/panel/annolist/module.ts b/public/app/plugins/panel/annolist/module.ts new file mode 100644 index 00000000000..4f7156361af --- /dev/null +++ b/public/app/plugins/panel/annolist/module.ts @@ -0,0 +1,16 @@ +import { AnnoListPanel } from './AnnoListPanel'; +import { AnnoOptions, defaults } from './types'; +import { AnnoListEditor } from './AnnoListEditor'; +import { PanelPlugin } from '@grafana/ui'; + +export const plugin = new PanelPlugin(AnnoListPanel) + .setDefaults(defaults) + .setEditor(AnnoListEditor) + + // TODO, we should support this directly in the plugin infrastructure + .setPanelChangeHandler((options: AnnoOptions, prevPluginId: string, prevOptions: any) => { + if (prevPluginId === 'ryantxu-annolist-panel') { + return prevOptions as AnnoOptions; + } + return options; + }); diff --git a/public/app/plugins/panel/annolist/plugin.json b/public/app/plugins/panel/annolist/plugin.json new file mode 100644 index 00000000000..d21890a0cb3 --- /dev/null +++ b/public/app/plugins/panel/annolist/plugin.json @@ -0,0 +1,20 @@ +{ + "type": "panel", + "name": "Annotations list (alpha)", + "id": "annolist", + "state": "alpha", + + "skipDataQuery": true, + + "info": { + "description": "List annotations", + "author": { + "name": "Grafana Project", + "url": "https://grafana.com" + }, + "logos": { + "small": "img/icn-annolist-panel.svg", + "large": "img/icn-annolist-panel.svg" + } + } +} diff --git a/public/app/plugins/panel/annolist/types.ts b/public/app/plugins/panel/annolist/types.ts new file mode 100644 index 00000000000..e78f562b214 --- /dev/null +++ b/public/app/plugins/panel/annolist/types.ts @@ -0,0 +1,29 @@ +export interface AnnoOptions { + limit: number; + tags: string[]; + onlyFromThisDashboard: boolean; + onlyInTimeRange: boolean; + + showTags: boolean; + showUser: boolean; + showTime: boolean; + + navigateBefore: string; + navigateAfter: string; + navigateToPanel: boolean; +} + +export const defaults: AnnoOptions = { + limit: 10, + tags: [], + onlyFromThisDashboard: false, + onlyInTimeRange: false, + + showTags: true, + showUser: true, + showTime: true, + + navigateBefore: '10m', + navigateAfter: '10m', + navigateToPanel: true, +};