AnnoListPanel.tsx 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314
  1. import { css } from '@emotion/css';
  2. import { FocusScope } from '@react-aria/focus';
  3. import React, { PureComponent } from 'react';
  4. import { Subscription } from 'rxjs';
  5. import {
  6. AnnotationChangeEvent,
  7. AnnotationEvent,
  8. AppEvents,
  9. dateTime,
  10. DurationUnit,
  11. GrafanaTheme,
  12. locationUtil,
  13. PanelProps,
  14. } from '@grafana/data';
  15. import { config, getBackendSrv, locationService } from '@grafana/runtime';
  16. import { CustomScrollbar, stylesFactory, TagList } from '@grafana/ui';
  17. import { AbstractList } from '@grafana/ui/src/components/List/AbstractList';
  18. import appEvents from 'app/core/app_events';
  19. import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
  20. import { AnnotationListItem } from './AnnotationListItem';
  21. import { AnnoOptions } from './types';
  22. interface UserInfo {
  23. id?: number;
  24. login?: string;
  25. email?: string;
  26. }
  27. export interface Props extends PanelProps<AnnoOptions> {}
  28. interface State {
  29. annotations: AnnotationEvent[];
  30. timeInfo: string;
  31. loaded: boolean;
  32. queryUser?: UserInfo;
  33. queryTags: string[];
  34. }
  35. export class AnnoListPanel extends PureComponent<Props, State> {
  36. style = getStyles(config.theme);
  37. subs = new Subscription();
  38. tagListRef = React.createRef<HTMLUListElement>();
  39. constructor(props: Props) {
  40. super(props);
  41. this.state = {
  42. annotations: [],
  43. timeInfo: '',
  44. loaded: false,
  45. queryTags: [],
  46. };
  47. }
  48. componentDidMount() {
  49. this.doSearch();
  50. // When an annotation on this dashboard changes, re-run the query
  51. this.subs.add(
  52. this.props.eventBus.getStream(AnnotationChangeEvent).subscribe({
  53. next: () => {
  54. this.doSearch();
  55. },
  56. })
  57. );
  58. }
  59. componentWillUnmount() {
  60. this.subs.unsubscribe();
  61. }
  62. componentDidUpdate(prevProps: Props, prevState: State) {
  63. const { options, timeRange } = this.props;
  64. const needsQuery =
  65. options !== prevProps.options ||
  66. this.state.queryTags !== prevState.queryTags ||
  67. this.state.queryUser !== prevState.queryUser ||
  68. (options.onlyInTimeRange && timeRange !== prevProps.timeRange);
  69. if (needsQuery) {
  70. this.doSearch();
  71. }
  72. }
  73. async doSearch() {
  74. // http://docs.grafana.org/http_api/annotations/
  75. // https://github.com/grafana/grafana/blob/main/public/app/core/services/backend_srv.ts
  76. // https://github.com/grafana/grafana/blob/main/public/app/features/annotations/annotations_srv.ts
  77. const { options } = this.props;
  78. const { queryUser, queryTags } = this.state;
  79. const params: any = {
  80. tags: options.tags,
  81. limit: options.limit,
  82. type: 'annotation', // Skip the Annotations that are really alerts. (Use the alerts panel!)
  83. };
  84. if (options.onlyFromThisDashboard) {
  85. params.dashboardId = getDashboardSrv().getCurrent()?.id;
  86. }
  87. let timeInfo = '';
  88. if (options.onlyInTimeRange) {
  89. const { timeRange } = this.props;
  90. params.from = timeRange.from.valueOf();
  91. params.to = timeRange.to.valueOf();
  92. } else {
  93. timeInfo = 'All Time';
  94. }
  95. if (queryUser) {
  96. params.userId = queryUser.id;
  97. }
  98. if (options.tags && options.tags.length) {
  99. params.tags = options.tags.map((tag) => this.props.replaceVariables(tag));
  100. }
  101. if (queryTags.length) {
  102. params.tags = params.tags ? [...params.tags, ...queryTags] : queryTags;
  103. }
  104. const annotations = await getBackendSrv().get('/api/annotations', params, `anno-list-panel-${this.props.id}`);
  105. this.setState({
  106. annotations,
  107. timeInfo,
  108. loaded: true,
  109. });
  110. }
  111. onAnnoClick = async (anno: AnnotationEvent) => {
  112. if (!anno.time) {
  113. return;
  114. }
  115. const { options } = this.props;
  116. const dashboardSrv = getDashboardSrv();
  117. const current = dashboardSrv.getCurrent();
  118. const params: any = {
  119. from: this._timeOffset(anno.time, options.navigateBefore, true),
  120. to: this._timeOffset(anno.timeEnd ?? anno.time, options.navigateAfter, false),
  121. };
  122. if (options.navigateToPanel) {
  123. params.viewPanel = anno.panelId;
  124. }
  125. if (current?.id === anno.dashboardId) {
  126. locationService.partial(params);
  127. return;
  128. }
  129. const result = await getBackendSrv().get('/api/search', { dashboardIds: anno.dashboardId });
  130. if (result && result.length && result[0].id === anno.dashboardId) {
  131. const dash = result[0];
  132. const url = new URL(dash.url, window.location.origin);
  133. url.searchParams.set('from', params.from);
  134. url.searchParams.set('to', params.to);
  135. locationService.push(locationUtil.stripBaseFromUrl(url.toString()));
  136. return;
  137. }
  138. appEvents.emit(AppEvents.alertWarning, ['Unknown Dashboard: ' + anno.dashboardId]);
  139. };
  140. _timeOffset(time: number, offset: string, subtract = false): number {
  141. let incr = 5;
  142. let unit = 'm';
  143. const parts = /^(\d+)(\w)/.exec(offset);
  144. if (parts && parts.length === 3) {
  145. incr = parseInt(parts[1], 10);
  146. unit = parts[2];
  147. }
  148. const t = dateTime(time);
  149. if (subtract) {
  150. incr *= -1;
  151. }
  152. return t.add(incr, unit as DurationUnit).valueOf();
  153. }
  154. onTagClick = (tag: string, remove?: boolean) => {
  155. if (!remove && this.state.queryTags.includes(tag)) {
  156. return;
  157. }
  158. const queryTags = remove ? this.state.queryTags.filter((item) => item !== tag) : [...this.state.queryTags, tag];
  159. // Logic to ensure keyboard focus isn't lost when the currently
  160. // focused tag is removed
  161. let nextTag: HTMLElement | undefined = undefined;
  162. if (remove) {
  163. const focusedTag = document.activeElement;
  164. const dataTagId = focusedTag?.getAttribute('data-tag-id');
  165. if (this.tagListRef.current?.contains(focusedTag) && dataTagId) {
  166. const parsedTagId = Number.parseInt(dataTagId, 10);
  167. const possibleNextTag =
  168. this.tagListRef.current.querySelector(`[data-tag-id="${parsedTagId + 1}"]`) ??
  169. this.tagListRef.current.querySelector(`[data-tag-id="${parsedTagId - 1}"]`);
  170. if (possibleNextTag instanceof HTMLElement) {
  171. nextTag = possibleNextTag;
  172. }
  173. }
  174. }
  175. this.setState({ queryTags }, () => nextTag?.focus());
  176. };
  177. onUserClick = (anno: AnnotationEvent) => {
  178. this.setState({
  179. queryUser: {
  180. id: anno.userId,
  181. login: anno.login,
  182. email: anno.email,
  183. },
  184. });
  185. };
  186. onClearUser = () => {
  187. this.setState({
  188. queryUser: undefined,
  189. });
  190. };
  191. renderItem = (anno: AnnotationEvent, index: number): JSX.Element => {
  192. const { options } = this.props;
  193. const dashboard = getDashboardSrv().getCurrent();
  194. if (!dashboard) {
  195. return <></>;
  196. }
  197. return (
  198. <AnnotationListItem
  199. annotation={anno}
  200. formatDate={dashboard.formatDate}
  201. onClick={this.onAnnoClick}
  202. onAvatarClick={this.onUserClick}
  203. onTagClick={this.onTagClick}
  204. options={options}
  205. />
  206. );
  207. };
  208. render() {
  209. const { loaded, annotations, queryUser, queryTags } = this.state;
  210. if (!loaded) {
  211. return <div>loading...</div>;
  212. }
  213. // Previously we showed inidication that it covered all time
  214. // { timeInfo && (
  215. // <span className="panel-time-info">
  216. // <Icon name="clock-nine" /> {timeInfo}
  217. // </span>
  218. // )}
  219. const hasFilter = queryUser || queryTags.length > 0;
  220. return (
  221. <CustomScrollbar autoHeightMin="100%">
  222. {hasFilter && (
  223. <div className={this.style.filter}>
  224. <b>Filter:</b>
  225. {queryUser && (
  226. <span onClick={this.onClearUser} className="pointer">
  227. {queryUser.email}
  228. </span>
  229. )}
  230. {queryTags.length > 0 && (
  231. <FocusScope restoreFocus>
  232. <TagList
  233. icon="times"
  234. tags={queryTags}
  235. onClick={(tag) => this.onTagClick(tag, true)}
  236. getAriaLabel={(name) => `Remove ${name} tag`}
  237. className={this.style.tagList}
  238. ref={this.tagListRef}
  239. />
  240. </FocusScope>
  241. )}
  242. </div>
  243. )}
  244. {annotations.length < 1 && <div className={this.style.noneFound}>No Annotations Found</div>}
  245. <AbstractList items={annotations} renderItem={this.renderItem} getItemKey={(item) => `${item.id}`} />
  246. </CustomScrollbar>
  247. );
  248. }
  249. }
  250. const getStyles = stylesFactory((theme: GrafanaTheme) => ({
  251. noneFound: css`
  252. display: flex;
  253. align-items: center;
  254. justify-content: center;
  255. width: 100%;
  256. height: calc(100% - 30px);
  257. `,
  258. filter: css({
  259. display: 'flex',
  260. padding: `0px ${theme.spacing.xs}`,
  261. b: {
  262. paddingRight: theme.spacing.sm,
  263. },
  264. }),
  265. tagList: css({
  266. justifyContent: 'flex-start',
  267. 'li > button': {
  268. paddingLeft: '3px',
  269. },
  270. }),
  271. }));