import { css } from '@emotion/css'; import { FocusScope } from '@react-aria/focus'; import React, { PureComponent } from 'react'; import { Subscription } from 'rxjs'; import { AnnotationChangeEvent, AnnotationEvent, AppEvents, dateTime, DurationUnit, GrafanaTheme, locationUtil, PanelProps, } from '@grafana/data'; import { config, getBackendSrv, locationService } from '@grafana/runtime'; import { CustomScrollbar, stylesFactory, TagList } from '@grafana/ui'; import { AbstractList } from '@grafana/ui/src/components/List/AbstractList'; import appEvents from 'app/core/app_events'; import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; import { AnnotationListItem } from './AnnotationListItem'; import { AnnoOptions } from './types'; interface UserInfo { id?: number; login?: string; email?: string; } export interface Props extends PanelProps {} interface State { annotations: AnnotationEvent[]; timeInfo: string; loaded: boolean; queryUser?: UserInfo; queryTags: string[]; } export class AnnoListPanel extends PureComponent { style = getStyles(config.theme); subs = new Subscription(); tagListRef = React.createRef(); constructor(props: Props) { super(props); this.state = { annotations: [], timeInfo: '', loaded: false, queryTags: [], }; } componentDidMount() { this.doSearch(); // When an annotation on this dashboard changes, re-run the query this.subs.add( this.props.eventBus.getStream(AnnotationChangeEvent).subscribe({ next: () => { this.doSearch(); }, }) ); } componentWillUnmount() { this.subs.unsubscribe(); } 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 || (options.onlyInTimeRange && timeRange !== prevProps.timeRange); if (needsQuery) { this.doSearch(); } } async doSearch() { // http://docs.grafana.org/http_api/annotations/ // https://github.com/grafana/grafana/blob/main/public/app/core/services/backend_srv.ts // https://github.com/grafana/grafana/blob/main/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.map((tag) => this.props.replaceVariables(tag)); } if (queryTags.length) { params.tags = params.tags ? [...params.tags, ...queryTags] : queryTags; } const annotations = await getBackendSrv().get('/api/annotations', params, `anno-list-panel-${this.props.id}`); this.setState({ annotations, timeInfo, loaded: true, }); } onAnnoClick = async (anno: AnnotationEvent) => { if (!anno.time) { return; } 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.timeEnd ?? anno.time, options.navigateAfter, false), }; if (options.navigateToPanel) { params.viewPanel = anno.panelId; } if (current?.id === anno.dashboardId) { locationService.partial(params); return; } const result = await getBackendSrv().get('/api/search', { dashboardIds: anno.dashboardId }); if (result && result.length && result[0].id === anno.dashboardId) { const dash = result[0]; const url = new URL(dash.url, window.location.origin); url.searchParams.set('from', params.from); url.searchParams.set('to', params.to); locationService.push(locationUtil.stripBaseFromUrl(url.toString())); return; } appEvents.emit(AppEvents.alertWarning, ['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 = (tag: string, remove?: boolean) => { if (!remove && this.state.queryTags.includes(tag)) { return; } const queryTags = remove ? this.state.queryTags.filter((item) => item !== tag) : [...this.state.queryTags, tag]; // Logic to ensure keyboard focus isn't lost when the currently // focused tag is removed let nextTag: HTMLElement | undefined = undefined; if (remove) { const focusedTag = document.activeElement; const dataTagId = focusedTag?.getAttribute('data-tag-id'); if (this.tagListRef.current?.contains(focusedTag) && dataTagId) { const parsedTagId = Number.parseInt(dataTagId, 10); const possibleNextTag = this.tagListRef.current.querySelector(`[data-tag-id="${parsedTagId + 1}"]`) ?? this.tagListRef.current.querySelector(`[data-tag-id="${parsedTagId - 1}"]`); if (possibleNextTag instanceof HTMLElement) { nextTag = possibleNextTag; } } } this.setState({ queryTags }, () => nextTag?.focus()); }; onUserClick = (anno: AnnotationEvent) => { this.setState({ queryUser: { id: anno.userId, login: anno.login, email: anno.email, }, }); }; onClearUser = () => { this.setState({ queryUser: undefined, }); }; renderItem = (anno: AnnotationEvent, index: number): JSX.Element => { const { options } = this.props; const dashboard = getDashboardSrv().getCurrent(); if (!dashboard) { return <>; } return ( ); }; render() { 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.onTagClick(tag, true)} getAriaLabel={(name) => `Remove ${name} tag`} className={this.style.tagList} ref={this.tagListRef} /> )}
)} {annotations.length < 1 &&
No Annotations Found
} `${item.id}`} />
); } } const getStyles = stylesFactory((theme: GrafanaTheme) => ({ noneFound: css` display: flex; align-items: center; justify-content: center; width: 100%; height: calc(100% - 30px); `, filter: css({ display: 'flex', padding: `0px ${theme.spacing.xs}`, b: { paddingRight: theme.spacing.sm, }, }), tagList: css({ justifyContent: 'flex-start', 'li > button': { paddingLeft: '3px', }, }), }));