PanelChrome.tsx 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584
  1. import classNames from 'classnames';
  2. import React, { PureComponent } from 'react';
  3. import { Subscription } from 'rxjs';
  4. import {
  5. AbsoluteTimeRange,
  6. AnnotationChangeEvent,
  7. AnnotationEventUIModel,
  8. CoreApp,
  9. DashboardCursorSync,
  10. EventFilterOptions,
  11. FieldConfigSource,
  12. getDefaultTimeRange,
  13. LoadingState,
  14. PanelData,
  15. PanelPlugin,
  16. PanelPluginMeta,
  17. TimeRange,
  18. toDataFrameDTO,
  19. toUtc,
  20. } from '@grafana/data';
  21. import { selectors } from '@grafana/e2e-selectors';
  22. import { locationService, RefreshEvent } from '@grafana/runtime';
  23. import { VizLegendOptions } from '@grafana/schema';
  24. import { ErrorBoundary, PanelContext, PanelContextProvider, SeriesVisibilityChangeMode } from '@grafana/ui';
  25. import config from 'app/core/config';
  26. import { PANEL_BORDER } from 'app/core/constants';
  27. import { profiler } from 'app/core/profiler';
  28. import { applyPanelTimeOverrides } from 'app/features/dashboard/utils/panel';
  29. import { changeSeriesColorConfigFactory } from 'app/plugins/panel/timeseries/overrides/colorSeriesConfigFactory';
  30. import { RenderEvent } from 'app/types/events';
  31. import { contextSrv } from '../../../core/services/context_srv';
  32. import { isSoloRoute } from '../../../routes/utils';
  33. import { deleteAnnotation, saveAnnotation, updateAnnotation } from '../../annotations/api';
  34. import { getDashboardQueryRunner } from '../../query/state/DashboardQueryRunner/DashboardQueryRunner';
  35. import { getTimeSrv, TimeSrv } from '../services/TimeSrv';
  36. import { DashboardModel, PanelModel } from '../state';
  37. import { loadSnapshotData } from '../utils/loadSnapshotData';
  38. import { PanelHeader } from './PanelHeader/PanelHeader';
  39. import { seriesVisibilityConfigFactory } from './SeriesVisibilityConfigFactory';
  40. import { liveTimer } from './liveTimer';
  41. const DEFAULT_PLUGIN_ERROR = 'Error in plugin';
  42. export interface Props {
  43. panel: PanelModel;
  44. dashboard: DashboardModel;
  45. plugin: PanelPlugin;
  46. isViewing: boolean;
  47. isEditing: boolean;
  48. isInView: boolean;
  49. width: number;
  50. height: number;
  51. onInstanceStateChange: (value: any) => void;
  52. }
  53. export interface State {
  54. isFirstLoad: boolean;
  55. renderCounter: number;
  56. errorMessage?: string;
  57. refreshWhenInView: boolean;
  58. context: PanelContext;
  59. data: PanelData;
  60. liveTime?: TimeRange;
  61. }
  62. export class PanelChrome extends PureComponent<Props, State> {
  63. private readonly timeSrv: TimeSrv = getTimeSrv();
  64. private subs = new Subscription();
  65. private eventFilter: EventFilterOptions = { onlyLocal: true };
  66. constructor(props: Props) {
  67. super(props);
  68. // Can this eventBus be on PanelModel? when we have more complex event filtering, that may be a better option
  69. const eventBus = props.dashboard.events.newScopedBus(`panel:${props.panel.id}`, this.eventFilter);
  70. this.state = {
  71. isFirstLoad: true,
  72. renderCounter: 0,
  73. refreshWhenInView: false,
  74. context: {
  75. eventBus,
  76. app: this.getPanelContextApp(),
  77. sync: this.getSync,
  78. onSeriesColorChange: this.onSeriesColorChange,
  79. onToggleSeriesVisibility: this.onSeriesVisibilityChange,
  80. onAnnotationCreate: this.onAnnotationCreate,
  81. onAnnotationUpdate: this.onAnnotationUpdate,
  82. onAnnotationDelete: this.onAnnotationDelete,
  83. canAddAnnotations: this.canAddAnnotation,
  84. onInstanceStateChange: this.onInstanceStateChange,
  85. onToggleLegendSort: this.onToggleLegendSort,
  86. canEditAnnotations: this.canEditAnnotation,
  87. canDeleteAnnotations: this.canDeleteAnnotation,
  88. },
  89. data: this.getInitialPanelDataState(),
  90. };
  91. }
  92. canEditDashboard = () => Boolean(this.props.dashboard.meta.canEdit || this.props.dashboard.meta.canMakeEditable);
  93. canAddAnnotation = () => {
  94. let canAdd = true;
  95. if (contextSrv.accessControlEnabled()) {
  96. canAdd = !!this.props.dashboard.meta.annotationsPermissions?.dashboard.canAdd;
  97. }
  98. return canAdd && this.canEditDashboard();
  99. };
  100. canEditAnnotation = (dashboardId: number) => {
  101. let canEdit = true;
  102. if (contextSrv.accessControlEnabled()) {
  103. if (dashboardId !== 0) {
  104. canEdit = !!this.props.dashboard.meta.annotationsPermissions?.dashboard.canEdit;
  105. } else {
  106. canEdit = !!this.props.dashboard.meta.annotationsPermissions?.organization.canEdit;
  107. }
  108. }
  109. return canEdit && this.canEditDashboard();
  110. };
  111. canDeleteAnnotation = (dashboardId: number) => {
  112. let canDelete = true;
  113. if (contextSrv.accessControlEnabled()) {
  114. if (dashboardId !== 0) {
  115. canDelete = !!this.props.dashboard.meta.annotationsPermissions?.dashboard.canDelete;
  116. } else {
  117. canDelete = !!this.props.dashboard.meta.annotationsPermissions?.organization.canDelete;
  118. }
  119. }
  120. return canDelete && this.canEditDashboard();
  121. };
  122. // Due to a mutable panel model we get the sync settings via function that proactively reads from the model
  123. getSync = () => (this.props.isEditing ? DashboardCursorSync.Off : this.props.dashboard.graphTooltip);
  124. onInstanceStateChange = (value: any) => {
  125. this.props.onInstanceStateChange(value);
  126. this.setState({
  127. context: {
  128. ...this.state.context,
  129. instanceState: value,
  130. },
  131. });
  132. };
  133. getPanelContextApp() {
  134. if (this.props.isEditing) {
  135. return CoreApp.PanelEditor;
  136. }
  137. if (this.props.isViewing) {
  138. return CoreApp.PanelViewer;
  139. }
  140. return CoreApp.Dashboard;
  141. }
  142. onSeriesColorChange = (label: string, color: string) => {
  143. this.onFieldConfigChange(changeSeriesColorConfigFactory(label, color, this.props.panel.fieldConfig));
  144. };
  145. onSeriesVisibilityChange = (label: string, mode: SeriesVisibilityChangeMode) => {
  146. this.onFieldConfigChange(
  147. seriesVisibilityConfigFactory(label, mode, this.props.panel.fieldConfig, this.state.data.series)
  148. );
  149. };
  150. onToggleLegendSort = (sortKey: string) => {
  151. const legendOptions: VizLegendOptions = this.props.panel.options.legend;
  152. // We don't want to do anything when legend options are not available
  153. if (!legendOptions) {
  154. return;
  155. }
  156. let sortDesc = legendOptions.sortDesc;
  157. let sortBy = legendOptions.sortBy;
  158. if (sortKey !== sortBy) {
  159. sortDesc = undefined;
  160. }
  161. // if already sort ascending, disable sorting
  162. if (sortDesc === false) {
  163. sortBy = undefined;
  164. sortDesc = undefined;
  165. } else {
  166. sortDesc = !sortDesc;
  167. sortBy = sortKey;
  168. }
  169. this.onOptionsChange({
  170. ...this.props.panel.options,
  171. legend: { ...legendOptions, sortBy, sortDesc },
  172. });
  173. };
  174. getInitialPanelDataState(): PanelData {
  175. return {
  176. state: LoadingState.NotStarted,
  177. series: [],
  178. timeRange: getDefaultTimeRange(),
  179. };
  180. }
  181. componentDidMount() {
  182. const { panel, dashboard } = this.props;
  183. // Subscribe to panel events
  184. this.subs.add(panel.events.subscribe(RefreshEvent, this.onRefresh));
  185. this.subs.add(panel.events.subscribe(RenderEvent, this.onRender));
  186. dashboard.panelInitialized(this.props.panel);
  187. // Move snapshot data into the query response
  188. if (this.hasPanelSnapshot) {
  189. this.setState({
  190. data: loadSnapshotData(panel, dashboard),
  191. isFirstLoad: false,
  192. });
  193. return;
  194. }
  195. if (!this.wantsQueryExecution) {
  196. this.setState({ isFirstLoad: false });
  197. }
  198. this.subs.add(
  199. panel
  200. .getQueryRunner()
  201. .getData({ withTransforms: true, withFieldConfig: true })
  202. .subscribe({
  203. next: (data) => this.onDataUpdate(data),
  204. })
  205. );
  206. // Listen for live timer events
  207. liveTimer.listen(this);
  208. }
  209. componentWillUnmount() {
  210. this.subs.unsubscribe();
  211. liveTimer.remove(this);
  212. }
  213. liveTimeChanged(liveTime: TimeRange) {
  214. const { data } = this.state;
  215. if (data.timeRange) {
  216. const delta = liveTime.to.valueOf() - data.timeRange.to.valueOf();
  217. if (delta < 100) {
  218. // 10hz
  219. console.log('Skip tick render', this.props.panel.title, delta);
  220. return;
  221. }
  222. }
  223. this.setState({ liveTime });
  224. }
  225. componentDidUpdate(prevProps: Props) {
  226. const { isInView, width } = this.props;
  227. const { context } = this.state;
  228. const app = this.getPanelContextApp();
  229. if (context.app !== app) {
  230. this.setState({
  231. context: {
  232. ...context,
  233. app,
  234. },
  235. });
  236. }
  237. // View state has changed
  238. if (isInView !== prevProps.isInView) {
  239. if (isInView) {
  240. // Check if we need a delayed refresh
  241. if (this.state.refreshWhenInView) {
  242. this.onRefresh();
  243. }
  244. }
  245. }
  246. // The timer depends on panel width
  247. if (width !== prevProps.width) {
  248. liveTimer.updateInterval(this);
  249. }
  250. }
  251. // Updates the response with information from the stream
  252. // The next is outside a react synthetic event so setState is not batched
  253. // So in this context we can only do a single call to setState
  254. onDataUpdate(data: PanelData) {
  255. const { dashboard, panel, plugin } = this.props;
  256. // Ignore this data update if we are now a non data panel
  257. if (plugin.meta.skipDataQuery) {
  258. this.setState({ data: this.getInitialPanelDataState() });
  259. return;
  260. }
  261. let { isFirstLoad } = this.state;
  262. let errorMessage: string | undefined;
  263. switch (data.state) {
  264. case LoadingState.Loading:
  265. // Skip updating state data if it is already in loading state
  266. // This is to avoid rendering partial loading responses
  267. if (this.state.data.state === LoadingState.Loading) {
  268. return;
  269. }
  270. break;
  271. case LoadingState.Error:
  272. const { error } = data;
  273. if (error) {
  274. if (errorMessage !== error.message) {
  275. errorMessage = error.message;
  276. }
  277. }
  278. break;
  279. case LoadingState.Done:
  280. // If we are doing a snapshot save data in panel model
  281. if (dashboard.snapshot) {
  282. panel.snapshotData = data.series.map((frame) => toDataFrameDTO(frame));
  283. }
  284. if (isFirstLoad) {
  285. isFirstLoad = false;
  286. }
  287. break;
  288. }
  289. this.setState({ isFirstLoad, errorMessage, data, liveTime: undefined });
  290. }
  291. onRefresh = () => {
  292. const { panel, isInView, width } = this.props;
  293. if (!isInView) {
  294. this.setState({ refreshWhenInView: true });
  295. return;
  296. }
  297. const timeData = applyPanelTimeOverrides(panel, this.timeSrv.timeRange());
  298. // Issue Query
  299. if (this.wantsQueryExecution) {
  300. if (width < 0) {
  301. return;
  302. }
  303. if (this.state.refreshWhenInView) {
  304. this.setState({ refreshWhenInView: false });
  305. }
  306. panel.runAllPanelQueries(this.props.dashboard.id, this.props.dashboard.getTimezone(), timeData, width);
  307. } else {
  308. // The panel should render on refresh as well if it doesn't have a query, like clock panel
  309. this.setState({
  310. data: { ...this.state.data, timeRange: this.timeSrv.timeRange() },
  311. renderCounter: this.state.renderCounter + 1,
  312. liveTime: undefined,
  313. });
  314. }
  315. };
  316. onRender = () => {
  317. const stateUpdate = { renderCounter: this.state.renderCounter + 1 };
  318. this.setState(stateUpdate);
  319. };
  320. onOptionsChange = (options: any) => {
  321. this.props.panel.updateOptions(options);
  322. };
  323. onFieldConfigChange = (config: FieldConfigSource) => {
  324. this.props.panel.updateFieldConfig(config);
  325. };
  326. onPanelError = (error: Error) => {
  327. const errorMessage = error.message || DEFAULT_PLUGIN_ERROR;
  328. if (this.state.errorMessage !== errorMessage) {
  329. this.setState({ errorMessage });
  330. }
  331. };
  332. onPanelErrorRecover = () => {
  333. this.setState({ errorMessage: undefined });
  334. };
  335. onAnnotationCreate = async (event: AnnotationEventUIModel) => {
  336. const isRegion = event.from !== event.to;
  337. const anno = {
  338. dashboardId: this.props.dashboard.id,
  339. panelId: this.props.panel.id,
  340. isRegion,
  341. time: event.from,
  342. timeEnd: isRegion ? event.to : 0,
  343. tags: event.tags,
  344. text: event.description,
  345. };
  346. await saveAnnotation(anno);
  347. getDashboardQueryRunner().run({ dashboard: this.props.dashboard, range: this.timeSrv.timeRange() });
  348. this.state.context.eventBus.publish(new AnnotationChangeEvent(anno));
  349. };
  350. onAnnotationDelete = async (id: string) => {
  351. await deleteAnnotation({ id });
  352. getDashboardQueryRunner().run({ dashboard: this.props.dashboard, range: this.timeSrv.timeRange() });
  353. this.state.context.eventBus.publish(new AnnotationChangeEvent({ id }));
  354. };
  355. onAnnotationUpdate = async (event: AnnotationEventUIModel) => {
  356. const isRegion = event.from !== event.to;
  357. const anno = {
  358. id: event.id,
  359. dashboardId: this.props.dashboard.id,
  360. panelId: this.props.panel.id,
  361. isRegion,
  362. time: event.from,
  363. timeEnd: isRegion ? event.to : 0,
  364. tags: event.tags,
  365. text: event.description,
  366. };
  367. await updateAnnotation(anno);
  368. getDashboardQueryRunner().run({ dashboard: this.props.dashboard, range: this.timeSrv.timeRange() });
  369. this.state.context.eventBus.publish(new AnnotationChangeEvent(anno));
  370. };
  371. get hasPanelSnapshot() {
  372. const { panel } = this.props;
  373. return panel.snapshotData && panel.snapshotData.length;
  374. }
  375. get wantsQueryExecution() {
  376. return !(this.props.plugin.meta.skipDataQuery || this.hasPanelSnapshot);
  377. }
  378. onChangeTimeRange = (timeRange: AbsoluteTimeRange) => {
  379. this.timeSrv.setTime({
  380. from: toUtc(timeRange.from),
  381. to: toUtc(timeRange.to),
  382. });
  383. };
  384. shouldSignalRenderingCompleted(loadingState: LoadingState, pluginMeta: PanelPluginMeta) {
  385. return loadingState === LoadingState.Done || pluginMeta.skipDataQuery;
  386. }
  387. skipFirstRender(loadingState: LoadingState) {
  388. const { isFirstLoad } = this.state;
  389. return (
  390. this.wantsQueryExecution &&
  391. isFirstLoad &&
  392. (loadingState === LoadingState.Loading || loadingState === LoadingState.NotStarted)
  393. );
  394. }
  395. renderPanel(width: number, height: number) {
  396. const { panel, plugin, dashboard } = this.props;
  397. const { renderCounter, data } = this.state;
  398. const { theme } = config;
  399. const { state: loadingState } = data;
  400. // do not render component until we have first data
  401. if (this.skipFirstRender(loadingState)) {
  402. return null;
  403. }
  404. // This is only done to increase a counter that is used by backend
  405. // image rendering to know when to capture image
  406. if (this.shouldSignalRenderingCompleted(loadingState, plugin.meta)) {
  407. profiler.renderingCompleted();
  408. }
  409. const PanelComponent = plugin.panel!;
  410. const timeRange = this.state.liveTime ?? data.timeRange ?? this.timeSrv.timeRange();
  411. const headerHeight = this.hasOverlayHeader() ? 0 : theme.panelHeaderHeight;
  412. const chromePadding = plugin.noPadding ? 0 : theme.panelPadding;
  413. const panelWidth = width - chromePadding * 2 - PANEL_BORDER;
  414. const innerPanelHeight = height - headerHeight - chromePadding * 2 - PANEL_BORDER;
  415. const panelContentClassNames = classNames({
  416. 'panel-content': true,
  417. 'panel-content--no-padding': plugin.noPadding,
  418. });
  419. const panelOptions = panel.getOptions();
  420. // Update the event filter (dashboard settings may have changed)
  421. // Yes this is called ever render for a function that is triggered on every mouse move
  422. this.eventFilter.onlyLocal = dashboard.graphTooltip === 0;
  423. return (
  424. <>
  425. <div className={panelContentClassNames}>
  426. <PanelContextProvider value={this.state.context}>
  427. <PanelComponent
  428. id={panel.id}
  429. data={data}
  430. title={panel.title}
  431. timeRange={timeRange}
  432. timeZone={this.props.dashboard.getTimezone()}
  433. options={panelOptions}
  434. fieldConfig={panel.fieldConfig}
  435. transparent={panel.transparent}
  436. width={panelWidth}
  437. height={innerPanelHeight}
  438. renderCounter={renderCounter}
  439. replaceVariables={panel.replaceVariables}
  440. onOptionsChange={this.onOptionsChange}
  441. onFieldConfigChange={this.onFieldConfigChange}
  442. onChangeTimeRange={this.onChangeTimeRange}
  443. eventBus={dashboard.events}
  444. />
  445. </PanelContextProvider>
  446. </div>
  447. </>
  448. );
  449. }
  450. hasOverlayHeader() {
  451. const { panel } = this.props;
  452. const { data } = this.state;
  453. // always show normal header if we have time override
  454. if (data.request && data.request.timeInfo) {
  455. return false;
  456. }
  457. return !panel.hasTitle();
  458. }
  459. render() {
  460. const { dashboard, panel, isViewing, isEditing, width, height, plugin } = this.props;
  461. const { errorMessage, data } = this.state;
  462. const { transparent } = panel;
  463. const alertState = data.alertState?.state;
  464. const containerClassNames = classNames({
  465. 'panel-container': true,
  466. 'panel-container--absolute': isSoloRoute(locationService.getLocation().pathname),
  467. 'panel-container--transparent': transparent,
  468. 'panel-container--no-title': this.hasOverlayHeader(),
  469. [`panel-alert-state--${alertState}`]: alertState !== undefined,
  470. });
  471. return (
  472. <section
  473. className={containerClassNames}
  474. aria-label={selectors.components.Panels.Panel.containerByTitle(panel.title)}
  475. >
  476. <PanelHeader
  477. panel={panel}
  478. dashboard={dashboard}
  479. title={panel.title}
  480. description={panel.description}
  481. links={panel.links}
  482. error={errorMessage}
  483. isEditing={isEditing}
  484. isViewing={isViewing}
  485. alertState={alertState}
  486. data={data}
  487. />
  488. <ErrorBoundary
  489. dependencies={[data, plugin, panel.getOptions()]}
  490. onError={this.onPanelError}
  491. onRecover={this.onPanelErrorRecover}
  492. >
  493. {({ error }) => {
  494. if (error) {
  495. return null;
  496. }
  497. return this.renderPanel(width, height);
  498. }}
  499. </ErrorBoundary>
  500. </section>
  501. );
  502. }
  503. }