1import React, { PureComponent } from 'react';
2import { Unsubscribable, PartialObserver } from 'rxjs';
3import { Alert, stylesFactory, Button, JSONFormatter, CustomScrollbar, CodeEditor } from '@grafana/ui';
4import {
5  GrafanaTheme,
6  PanelProps,
7  LiveChannelStatusEvent,
8  isValidLiveChannelAddress,
9  LiveChannelEvent,
10  isLiveChannelStatusEvent,
11  isLiveChannelMessageEvent,
12  LiveChannelConnectionState,
13  PanelData,
14  LoadingState,
15  applyFieldOverrides,
16  StreamingDataFrame,
17  LiveChannelAddress,
18} from '@grafana/data';
19import { TablePanel } from '../table/TablePanel';
20import { LivePanelOptions, MessageDisplayMode } from './types';
21import { config, getGrafanaLiveSrv } from '@grafana/runtime';
22import { css, cx } from '@emotion/css';
23import { isEqual } from 'lodash';
24
25interface Props extends PanelProps<LivePanelOptions> {}
26
27interface State {
28  error?: any;
29  addr?: LiveChannelAddress;
30  status?: LiveChannelStatusEvent;
31  message?: any;
32  changed: number;
33}
34
35export class LivePanel extends PureComponent<Props, State> {
36  private readonly isValid: boolean;
37  subscription?: Unsubscribable;
38  styles = getStyles(config.theme);
39
40  constructor(props: Props) {
41    super(props);
42
43    this.isValid = !!getGrafanaLiveSrv();
44    this.state = { changed: 0 };
45  }
46
47  async componentDidMount() {
48    this.loadChannel();
49  }
50
51  componentWillUnmount() {
52    if (this.subscription) {
53      this.subscription.unsubscribe();
54    }
55  }
56
57  componentDidUpdate(prevProps: Props): void {
58    if (this.props.options?.channel !== prevProps.options?.channel) {
59      this.loadChannel();
60    }
61  }
62
63  streamObserver: PartialObserver<LiveChannelEvent> = {
64    next: (event: LiveChannelEvent) => {
65      if (isLiveChannelStatusEvent(event)) {
66        this.setState({ status: event, changed: Date.now() });
67      } else if (isLiveChannelMessageEvent(event)) {
68        this.setState({ message: event.message, changed: Date.now() });
69      } else {
70        console.log('ignore', event);
71      }
72    },
73  };
74
75  unsubscribe = () => {
76    if (this.subscription) {
77      this.subscription.unsubscribe();
78      this.subscription = undefined;
79    }
80  };
81
82  async loadChannel() {
83    const addr = this.props.options?.channel;
84    if (!isValidLiveChannelAddress(addr)) {
85      console.log('INVALID', addr);
86      this.unsubscribe();
87      this.setState({
88        addr: undefined,
89      });
90      return;
91    }
92
93    if (isEqual(addr, this.state.addr)) {
94      console.log('Same channel', this.state.addr);
95      return;
96    }
97
98    const live = getGrafanaLiveSrv();
99    if (!live) {
100      console.log('INVALID', addr);
101      this.unsubscribe();
102      this.setState({
103        addr: undefined,
104      });
105      return;
106    }
107    this.unsubscribe();
108
109    console.log('LOAD', addr);
110
111    // Subscribe to new events
112    try {
113      this.subscription = live.getStream(addr).subscribe(this.streamObserver);
114      this.setState({ addr, error: undefined });
115    } catch (err) {
116      this.setState({ addr: undefined, error: err });
117    }
118  }
119
120  renderNotEnabled() {
121    const preformatted = `[feature_toggles]
122    enable = live`;
123    return (
124      <Alert title="Grafana Live" severity="info">
125        <p>Grafana live requires a feature flag to run</p>
126
127        <b>custom.ini:</b>
128        <pre>{preformatted}</pre>
129      </Alert>
130    );
131  }
132
133  onSaveJSON = (text: string) => {
134    const { options, onOptionsChange } = this.props;
135
136    try {
137      const json = JSON.parse(text);
138      onOptionsChange({ ...options, json });
139    } catch (err) {
140      console.log('Error reading JSON', err);
141    }
142  };
143
144  onPublishClicked = async () => {
145    const { addr } = this.state;
146    if (!addr) {
147      console.log('invalid address');
148      return;
149    }
150
151    const data = this.props.options?.json;
152    if (!data) {
153      console.log('nothing to publish');
154      return;
155    }
156
157    const rsp = await getGrafanaLiveSrv().publish(addr, data);
158    console.log('onPublishClicked (response from publish)', rsp);
159  };
160
161  renderMessage(height: number) {
162    const { options } = this.props;
163    const { message } = this.state;
164
165    if (!message) {
166      return (
167        <div>
168          <h4>Waiting for data:</h4>
169          {options.channel?.scope}/{options.channel?.namespace}/{options.channel?.path}
170        </div>
171      );
172    }
173
174    if (options.message === MessageDisplayMode.JSON) {
175      return <JSONFormatter json={message} open={5} />;
176    }
177
178    if (options.message === MessageDisplayMode.Auto) {
179      if (message instanceof StreamingDataFrame) {
180        const data: PanelData = {
181          series: applyFieldOverrides({
182            data: [message],
183            theme: config.theme2,
184            replaceVariables: (v: string) => v,
185            fieldConfig: {
186              defaults: {},
187              overrides: [],
188            },
189          }),
190          state: LoadingState.Streaming,
191        } as PanelData;
192        const props = {
193          ...this.props,
194          options: { frameIndex: 0, showHeader: true },
195        } as PanelProps<any>;
196        return <TablePanel {...props} data={data} height={height} />;
197      }
198    }
199
200    return <pre>{JSON.stringify(message)}</pre>;
201  }
202
203  renderPublish(height: number) {
204    const { options } = this.props;
205    return (
206      <>
207        <CodeEditor
208          height={height - 32}
209          language="json"
210          value={options.json ? JSON.stringify(options.json, null, 2) : '{ }'}
211          onBlur={this.onSaveJSON}
212          onSave={this.onSaveJSON}
213          showMiniMap={false}
214          showLineNumbers={true}
215        />
216        <div style={{ height: 32 }}>
217          <Button onClick={this.onPublishClicked}>Publish</Button>
218        </div>
219      </>
220    );
221  }
222
223  renderStatus() {
224    const { status } = this.state;
225    if (status?.state === LiveChannelConnectionState.Connected) {
226      return; // nothing
227    }
228
229    let statusClass = '';
230    if (status) {
231      statusClass = this.styles.status[status.state];
232    }
233    return <div className={cx(statusClass, this.styles.statusWrap)}>{status?.state}</div>;
234  }
235
236  renderBody() {
237    const { status } = this.state;
238    const { options, height } = this.props;
239
240    if (options.publish) {
241      // Only the publish form
242      if (options.message === MessageDisplayMode.None) {
243        return <div>{this.renderPublish(height)}</div>;
244      }
245      // Both message and publish
246      const halfHeight = height / 2;
247      return (
248        <div>
249          <div style={{ height: halfHeight, overflow: 'hidden' }}>
250            <CustomScrollbar autoHeightMin="100%" autoHeightMax="100%">
251              {this.renderMessage(halfHeight)}
252            </CustomScrollbar>
253          </div>
254          <div>{this.renderPublish(halfHeight)}</div>
255        </div>
256      );
257    }
258    if (options.message === MessageDisplayMode.None) {
259      return <pre>{JSON.stringify(status)}</pre>;
260    }
261
262    // Only message
263    return (
264      <div style={{ overflow: 'hidden', height }}>
265        <CustomScrollbar autoHeightMin="100%" autoHeightMax="100%">
266          {this.renderMessage(height)}
267        </CustomScrollbar>
268      </div>
269    );
270  }
271
272  render() {
273    if (!this.isValid) {
274      return this.renderNotEnabled();
275    }
276    const { addr, error } = this.state;
277    if (!addr) {
278      return (
279        <Alert title="Grafana Live" severity="info">
280          Use the panel editor to pick a channel
281        </Alert>
282      );
283    }
284    if (error) {
285      return (
286        <div>
287          <h2>ERROR</h2>
288          <div>{JSON.stringify(error)}</div>
289        </div>
290      );
291    }
292    return (
293      <>
294        {this.renderStatus()}
295        {this.renderBody()}
296      </>
297    );
298  }
299}
300
301const getStyles = stylesFactory((theme: GrafanaTheme) => ({
302  statusWrap: css`
303    margin: auto;
304    position: absolute;
305    top: 0;
306    right: 0;
307    background: ${theme.colors.panelBg};
308    padding: 10px;
309    z-index: ${theme.zIndex.modal};
310  `,
311  status: {
312    [LiveChannelConnectionState.Pending]: css`
313      border: 1px solid ${theme.palette.brandPrimary};
314    `,
315    [LiveChannelConnectionState.Connected]: css`
316      border: 1px solid ${theme.palette.brandSuccess};
317    `,
318    [LiveChannelConnectionState.Disconnected]: css`
319      border: 1px solid ${theme.palette.brandWarning};
320    `,
321    [LiveChannelConnectionState.Shutdown]: css`
322      border: 1px solid ${theme.palette.brandDanger};
323    `,
324    [LiveChannelConnectionState.Invalid]: css`
325      border: 1px solid red;
326    `,
327  },
328}));
329