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