mirror of https://github.com/grafana/grafana
Live: broadcast events when dashboard is saved (#27583)
Co-authored-by: kay delaney <45561153+kaydelaney@users.noreply.github.com> Co-authored-by: Torkel Ödegaard <torkel@grafana.org>pull/27944/head^2
parent
44c9aea28c
commit
8a5fc00330
@ -0,0 +1,30 @@ |
||||
package models |
||||
|
||||
import "github.com/centrifugal/centrifuge" |
||||
|
||||
// ChannelPublisher writes data into a channel
|
||||
type ChannelPublisher func(channel string, data []byte) error |
||||
|
||||
// ChannelHandler defines the core channel behavior
|
||||
type ChannelHandler interface { |
||||
// This is called fast and often -- it must be synchrnozed
|
||||
GetChannelOptions(id string) centrifuge.ChannelOptions |
||||
|
||||
// Called when a client wants to subscribe to a channel
|
||||
OnSubscribe(c *centrifuge.Client, e centrifuge.SubscribeEvent) error |
||||
|
||||
// Called when something writes into the channel. The returned value will be broadcast if len() > 0
|
||||
OnPublish(c *centrifuge.Client, e centrifuge.PublishEvent) ([]byte, error) |
||||
} |
||||
|
||||
// ChannelHandlerProvider -- this should be implemented by any core feature
|
||||
type ChannelHandlerProvider interface { |
||||
// This is called fast and often -- it must be synchrnozed
|
||||
GetHandlerForPath(path string) (ChannelHandler, error) |
||||
} |
||||
|
||||
// DashboardActivityChannel is a service to advertise dashboard activity
|
||||
type DashboardActivityChannel interface { |
||||
DashboardSaved(uid string, userID int64) error |
||||
DashboardDeleted(uid string, userID int64) error |
||||
} |
||||
@ -0,0 +1,27 @@ |
||||
package live |
||||
|
||||
import ( |
||||
"fmt" |
||||
"strings" |
||||
) |
||||
|
||||
// ChannelIdentifier is the channel id split by parts
|
||||
type ChannelIdentifier struct { |
||||
Scope string // grafana, ds, or plugin
|
||||
Namespace string // feature, id, or name
|
||||
Path string // path within the channel handler
|
||||
} |
||||
|
||||
// ParseChannelIdentifier parses the parts from a channel id:
|
||||
// ${scope} / ${namespace} / ${path}
|
||||
func ParseChannelIdentifier(id string) (ChannelIdentifier, error) { |
||||
parts := strings.SplitN(id, "/", 3) |
||||
if len(parts) == 3 { |
||||
return ChannelIdentifier{ |
||||
Scope: parts[0], |
||||
Namespace: parts[1], |
||||
Path: parts[2], |
||||
}, nil |
||||
} |
||||
return ChannelIdentifier{}, fmt.Errorf("Invalid channel id: %s", id) |
||||
} |
||||
@ -0,0 +1,30 @@ |
||||
package live |
||||
|
||||
import ( |
||||
"testing" |
||||
|
||||
"github.com/google/go-cmp/cmp" |
||||
) |
||||
|
||||
func TestParseChannelIdentifier(t *testing.T) { |
||||
ident, err := ParseChannelIdentifier("aaa/bbb/ccc/ddd") |
||||
if err != nil { |
||||
t.FailNow() |
||||
} |
||||
|
||||
ex := ChannelIdentifier{ |
||||
Scope: "aaa", |
||||
Namespace: "bbb", |
||||
Path: "ccc/ddd", |
||||
} |
||||
|
||||
if diff := cmp.Diff(ident, ex); diff != "" { |
||||
t.Fatalf("Result mismatch (-want +got):\n%s", diff) |
||||
} |
||||
|
||||
// Check an invalid identifier
|
||||
_, err = ParseChannelIdentifier("aaa/bbb") |
||||
if err == nil { |
||||
t.FailNow() |
||||
} |
||||
} |
||||
@ -1,54 +0,0 @@ |
||||
package live |
||||
|
||||
import ( |
||||
"encoding/json" |
||||
"math/rand" |
||||
"time" |
||||
) |
||||
|
||||
// channelInfo holds metadata about each channel and is returned on connection.
|
||||
// Eventually each plugin should control exactly what is in this structure.
|
||||
type channelInfo struct { |
||||
Description string |
||||
} |
||||
|
||||
type randomWalkMessage struct { |
||||
Time int64 |
||||
Value float64 |
||||
Min float64 |
||||
Max float64 |
||||
} |
||||
|
||||
// RunRandomCSV just for an example
|
||||
func RunRandomCSV(broker *GrafanaLive, channel string, speedMillis int, dropPercent float64) { |
||||
spread := 50.0 |
||||
|
||||
walker := rand.Float64() * 100 |
||||
ticker := time.NewTicker(time.Duration(speedMillis) * time.Millisecond) |
||||
|
||||
line := randomWalkMessage{} |
||||
|
||||
for t := range ticker.C { |
||||
if rand.Float64() <= dropPercent { |
||||
continue //
|
||||
} |
||||
delta := rand.Float64() - 0.5 |
||||
walker += delta |
||||
|
||||
line.Time = t.UnixNano() / int64(time.Millisecond) |
||||
line.Value = walker |
||||
line.Min = walker - ((rand.Float64() * spread) + 0.01) |
||||
line.Max = walker + ((rand.Float64() * spread) + 0.01) |
||||
|
||||
bytes, err := json.Marshal(&line) |
||||
if err != nil { |
||||
logger.Warn("unable to marshal line", "error", err) |
||||
continue |
||||
} |
||||
|
||||
v := broker.Publish(channel, bytes) |
||||
if !v { |
||||
logger.Warn("write", "channel", channel, "line", line, "ok", v) |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,32 @@ |
||||
package features |
||||
|
||||
import ( |
||||
"github.com/centrifugal/centrifuge" |
||||
"github.com/grafana/grafana/pkg/models" |
||||
) |
||||
|
||||
// BroadcastRunner will simply broadcast all events to `grafana/broadcast/*` channels
|
||||
// This makes no assumptions about the shape of the data and will broadcast it to anyone listening
|
||||
type BroadcastRunner struct{} |
||||
|
||||
// GetHandlerForPath called on init
|
||||
func (g *BroadcastRunner) GetHandlerForPath(path string) (models.ChannelHandler, error) { |
||||
return g, nil // for now all channels share config
|
||||
} |
||||
|
||||
// GetChannelOptions called fast and often
|
||||
func (g *BroadcastRunner) GetChannelOptions(id string) centrifuge.ChannelOptions { |
||||
return centrifuge.ChannelOptions{} |
||||
} |
||||
|
||||
// OnSubscribe for now allows anyone to subscribe to any dashboard
|
||||
func (g *BroadcastRunner) OnSubscribe(c *centrifuge.Client, e centrifuge.SubscribeEvent) error { |
||||
// anyone can subscribe
|
||||
return nil |
||||
} |
||||
|
||||
// OnPublish called when an event is received from the websocket
|
||||
func (g *BroadcastRunner) OnPublish(c *centrifuge.Client, e centrifuge.PublishEvent) ([]byte, error) { |
||||
// expect the data to be the right shape?
|
||||
return e.Data, nil |
||||
} |
||||
@ -0,0 +1,80 @@ |
||||
package features |
||||
|
||||
import ( |
||||
"encoding/json" |
||||
|
||||
"github.com/centrifugal/centrifuge" |
||||
"github.com/grafana/grafana/pkg/models" |
||||
) |
||||
|
||||
// DashboardEvent events related to dashboards
|
||||
type dashboardEvent struct { |
||||
UID string `json:"uid"` |
||||
Action string `json:"action"` // saved, editing
|
||||
UserID int64 `json:"userId,omitempty"` |
||||
SessionID string `json:"sessionId,omitempty"` |
||||
} |
||||
|
||||
// DashboardHandler manages all the `grafana/dashboard/*` channels
|
||||
type DashboardHandler struct { |
||||
publisher models.ChannelPublisher |
||||
} |
||||
|
||||
// CreateDashboardHandler Initialize a dashboard handler
|
||||
func CreateDashboardHandler(p models.ChannelPublisher) DashboardHandler { |
||||
return DashboardHandler{ |
||||
publisher: p, |
||||
} |
||||
} |
||||
|
||||
// GetHandlerForPath called on init
|
||||
func (g *DashboardHandler) GetHandlerForPath(path string) (models.ChannelHandler, error) { |
||||
return g, nil // all dashboards share the same handler
|
||||
} |
||||
|
||||
// GetChannelOptions called fast and often
|
||||
func (g *DashboardHandler) GetChannelOptions(id string) centrifuge.ChannelOptions { |
||||
return centrifuge.ChannelOptions{ |
||||
Presence: true, |
||||
JoinLeave: true, // if enterprise?
|
||||
} |
||||
} |
||||
|
||||
// OnSubscribe for now allows anyone to subscribe to any dashboard
|
||||
func (g *DashboardHandler) OnSubscribe(c *centrifuge.Client, e centrifuge.SubscribeEvent) error { |
||||
// TODO? check authentication
|
||||
return nil |
||||
} |
||||
|
||||
// OnPublish called when an event is received from the websocket
|
||||
func (g *DashboardHandler) OnPublish(c *centrifuge.Client, e centrifuge.PublishEvent) ([]byte, error) { |
||||
// TODO -- verify and keep track of editors?
|
||||
return e.Data, nil |
||||
} |
||||
|
||||
// DashboardSaved should broadcast to the appropriate stream
|
||||
func (g *DashboardHandler) publish(event dashboardEvent) error { |
||||
msg, err := json.Marshal(event) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
return g.publisher("grafana/dashboard/"+event.UID, msg) |
||||
} |
||||
|
||||
// DashboardSaved will broadcast to all connected dashboards
|
||||
func (g *DashboardHandler) DashboardSaved(uid string, userID int64) error { |
||||
return g.publish(dashboardEvent{ |
||||
UID: uid, |
||||
Action: "saved", |
||||
UserID: userID, |
||||
}) |
||||
} |
||||
|
||||
// DashboardDeleted will broadcast to all connected dashboards
|
||||
func (g *DashboardHandler) DashboardDeleted(uid string, userID int64) error { |
||||
return g.publish(dashboardEvent{ |
||||
UID: uid, |
||||
Action: "deleted", |
||||
UserID: userID, |
||||
}) |
||||
} |
||||
@ -0,0 +1,124 @@ |
||||
package features |
||||
|
||||
import ( |
||||
"encoding/json" |
||||
"fmt" |
||||
"math/rand" |
||||
"time" |
||||
|
||||
"github.com/centrifugal/centrifuge" |
||||
"github.com/grafana/grafana/pkg/cmd/grafana-cli/logger" |
||||
"github.com/grafana/grafana/pkg/models" |
||||
) |
||||
|
||||
// TestdataRunner manages all the `grafana/dashboard/*` channels
|
||||
type testdataRunner struct { |
||||
publisher models.ChannelPublisher |
||||
running bool |
||||
speedMillis int |
||||
dropPercent float64 |
||||
channel string |
||||
} |
||||
|
||||
// TestdataSupplier manages all the `grafana/testdata/*` channels
|
||||
type TestdataSupplier struct { |
||||
publisher models.ChannelPublisher |
||||
} |
||||
|
||||
// CreateTestdataSupplier Initialize a dashboard handler
|
||||
func CreateTestdataSupplier(p models.ChannelPublisher) TestdataSupplier { |
||||
return TestdataSupplier{ |
||||
publisher: p, |
||||
} |
||||
} |
||||
|
||||
// GetHandlerForPath called on init
|
||||
func (g *TestdataSupplier) GetHandlerForPath(path string) (models.ChannelHandler, error) { |
||||
channel := "grafana/testdata/" + path |
||||
|
||||
if path == "random-2s-stream" { |
||||
return &testdataRunner{ |
||||
publisher: g.publisher, |
||||
running: false, |
||||
speedMillis: 2000, |
||||
dropPercent: 0, |
||||
channel: channel, |
||||
}, nil |
||||
} |
||||
|
||||
if path == "random-flakey-stream" { |
||||
return &testdataRunner{ |
||||
publisher: g.publisher, |
||||
running: false, |
||||
speedMillis: 400, |
||||
dropPercent: .6, |
||||
channel: channel, |
||||
}, nil |
||||
} |
||||
|
||||
return nil, fmt.Errorf("unknown channel") |
||||
} |
||||
|
||||
// GetChannelOptions called fast and often
|
||||
func (g *testdataRunner) GetChannelOptions(id string) centrifuge.ChannelOptions { |
||||
return centrifuge.ChannelOptions{} |
||||
} |
||||
|
||||
// OnSubscribe for now allows anyone to subscribe to any dashboard
|
||||
func (g *testdataRunner) OnSubscribe(c *centrifuge.Client, e centrifuge.SubscribeEvent) error { |
||||
if !g.running { |
||||
g.running = true |
||||
|
||||
// Run in the background
|
||||
go g.runRandomCSV() |
||||
} |
||||
|
||||
// TODO? check authentication
|
||||
return nil |
||||
} |
||||
|
||||
// OnPublish called when an event is received from the websocket
|
||||
func (g *testdataRunner) OnPublish(c *centrifuge.Client, e centrifuge.PublishEvent) ([]byte, error) { |
||||
return nil, fmt.Errorf("can not publish to testdata") |
||||
} |
||||
|
||||
type randomWalkMessage struct { |
||||
Time int64 |
||||
Value float64 |
||||
Min float64 |
||||
Max float64 |
||||
} |
||||
|
||||
// RunRandomCSV just for an example
|
||||
func (g *testdataRunner) runRandomCSV() { |
||||
spread := 50.0 |
||||
|
||||
walker := rand.Float64() * 100 |
||||
ticker := time.NewTicker(time.Duration(g.speedMillis) * time.Millisecond) |
||||
|
||||
line := randomWalkMessage{} |
||||
|
||||
for t := range ticker.C { |
||||
if rand.Float64() <= g.dropPercent { |
||||
continue |
||||
} |
||||
delta := rand.Float64() - 0.5 |
||||
walker += delta |
||||
|
||||
line.Time = t.UnixNano() / int64(time.Millisecond) |
||||
line.Value = walker |
||||
line.Min = walker - ((rand.Float64() * spread) + 0.01) |
||||
line.Max = walker + ((rand.Float64() * spread) + 0.01) |
||||
|
||||
bytes, err := json.Marshal(&line) |
||||
if err != nil { |
||||
logger.Warn("unable to marshal line", "error", err) |
||||
continue |
||||
} |
||||
|
||||
err = g.publisher(g.channel, bytes) |
||||
if err != nil { |
||||
logger.Warn("write", "channel", g.channel, "line", line) |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,32 @@ |
||||
package live |
||||
|
||||
import ( |
||||
"github.com/centrifugal/centrifuge" |
||||
"github.com/grafana/grafana/pkg/models" |
||||
"github.com/grafana/grafana/pkg/plugins" |
||||
) |
||||
|
||||
// PluginHandler manages all the `grafana/dashboard/*` channels
|
||||
type PluginHandler struct { |
||||
Plugin *plugins.PluginBase |
||||
} |
||||
|
||||
// GetHandlerForPath called on init
|
||||
func (g *PluginHandler) GetHandlerForPath(path string) (models.ChannelHandler, error) { |
||||
return g, nil // all dashboards share the same handler
|
||||
} |
||||
|
||||
// GetChannelOptions called fast and often
|
||||
func (g *PluginHandler) GetChannelOptions(id string) centrifuge.ChannelOptions { |
||||
return centrifuge.ChannelOptions{} |
||||
} |
||||
|
||||
// OnSubscribe for now allows anyone to subscribe to any dashboard
|
||||
func (g *PluginHandler) OnSubscribe(c *centrifuge.Client, e centrifuge.SubscribeEvent) error { |
||||
return nil // anyone can subscribe
|
||||
} |
||||
|
||||
// OnPublish called when an event is received from the websocket
|
||||
func (g *PluginHandler) OnPublish(c *centrifuge.Client, e centrifuge.PublishEvent) ([]byte, error) { |
||||
return e.Data, nil // broadcast any event
|
||||
} |
||||
@ -0,0 +1,112 @@ |
||||
import React, { PureComponent } from 'react'; |
||||
import { Modal, stylesFactory, VerticalGroup } from '@grafana/ui'; |
||||
import { css } from 'emotion'; |
||||
import { dashboardWatcher } from './dashboardWatcher'; |
||||
import { config } from '@grafana/runtime'; |
||||
import { DashboardEvent, DashboardEventAction } from './types'; |
||||
import { GrafanaTheme } from '@grafana/data'; |
||||
|
||||
interface Props { |
||||
event?: DashboardEvent; |
||||
} |
||||
|
||||
interface State { |
||||
dismiss?: boolean; |
||||
} |
||||
|
||||
interface ActionInfo { |
||||
label: string; |
||||
description: string; |
||||
action: () => void; |
||||
} |
||||
|
||||
export class DashboardChangedModal extends PureComponent<Props, State> { |
||||
state: State = {}; |
||||
|
||||
discardAndReload: ActionInfo = { |
||||
label: 'Discard local changes', |
||||
description: 'Load the latest saved version for this dashboard', |
||||
action: () => { |
||||
dashboardWatcher.reloadPage(); |
||||
this.onDismiss(); |
||||
}, |
||||
}; |
||||
|
||||
continueEditing: ActionInfo = { |
||||
label: 'Continue editing', |
||||
description: |
||||
'Keep your local changes and continue editing. Note: when you save, this will overwrite the most recent chages', |
||||
action: () => { |
||||
this.onDismiss(); |
||||
}, |
||||
}; |
||||
|
||||
acceptDelete: ActionInfo = { |
||||
label: 'Discard Local changes', |
||||
description: 'view grafana homepage', |
||||
action: () => { |
||||
// Navigate to the root URL
|
||||
document.location.href = config.appUrl; |
||||
}, |
||||
}; |
||||
|
||||
onDismiss = () => { |
||||
this.setState({ dismiss: true }); |
||||
}; |
||||
|
||||
render() { |
||||
const { event } = this.props; |
||||
const { dismiss } = this.state; |
||||
const styles = getStyles(config.theme); |
||||
|
||||
const isDelete = event?.action === DashboardEventAction.Deleted; |
||||
|
||||
const options = isDelete |
||||
? [this.continueEditing, this.acceptDelete] |
||||
: [this.continueEditing, this.discardAndReload]; |
||||
|
||||
return ( |
||||
<Modal |
||||
isOpen={!dismiss} |
||||
title="Dashboard Changed" |
||||
icon="copy" |
||||
onDismiss={this.onDismiss} |
||||
className={styles.modal} |
||||
> |
||||
<div> |
||||
{isDelete ? ( |
||||
<div>This dashboard has been deleted by another session</div> |
||||
) : ( |
||||
<div>This dashboard has been modifed by another session</div> |
||||
)} |
||||
<br /> |
||||
<VerticalGroup> |
||||
{options.map(opt => { |
||||
return ( |
||||
<div key={opt.label} onClick={opt.action} className={styles.radioItem}> |
||||
<h3>{opt.label}</h3> |
||||
{opt.description} |
||||
</div> |
||||
); |
||||
})} |
||||
</VerticalGroup> |
||||
<br /> |
||||
</div> |
||||
</Modal> |
||||
); |
||||
} |
||||
} |
||||
|
||||
const getStyles = stylesFactory((theme: GrafanaTheme) => { |
||||
return { |
||||
modal: css` |
||||
width: 500px; |
||||
`,
|
||||
radioItem: css` |
||||
margin: 0; |
||||
margin-left: ${theme.spacing.md}; |
||||
font-size: ${theme.typography.size.sm}; |
||||
color: ${theme.colors.textWeak}; |
||||
`,
|
||||
}; |
||||
}); |
||||
@ -0,0 +1,168 @@ |
||||
import { getGrafanaLiveSrv, getLegacyAngularInjector } from '@grafana/runtime'; |
||||
import { getDashboardSrv } from '../../dashboard/services/DashboardSrv'; |
||||
import { appEvents } from 'app/core/core'; |
||||
import { |
||||
AppEvents, |
||||
LiveChannel, |
||||
LiveChannelScope, |
||||
LiveChannelEvent, |
||||
LiveChannelConfig, |
||||
LiveChannelConnectionState, |
||||
isLiveChannelStatusEvent, |
||||
isLiveChannelMessageEvent, |
||||
} from '@grafana/data'; |
||||
import { CoreEvents } from 'app/types'; |
||||
import { DashboardChangedModal } from './DashboardChangedModal'; |
||||
import { DashboardEvent, DashboardEventAction } from './types'; |
||||
import { CoreGrafanaLiveFeature } from '../scopes'; |
||||
import { sessionId } from '../live'; |
||||
|
||||
class DashboardWatcher { |
||||
channel?: LiveChannel<DashboardEvent>; |
||||
|
||||
uid?: string; |
||||
ignoreSave?: boolean; |
||||
editing = false; |
||||
|
||||
setEditingState(state: boolean) { |
||||
const changed = (this.editing = state); |
||||
this.editing = state; |
||||
|
||||
if (changed) { |
||||
this.sendEditingState(); |
||||
} |
||||
} |
||||
|
||||
private sendEditingState() { |
||||
if (!this.channel?.publish) { |
||||
return; |
||||
} |
||||
|
||||
const msg: DashboardEvent = { |
||||
sessionId, |
||||
uid: this.uid!, |
||||
action: this.editing ? DashboardEventAction.EditingStarted : DashboardEventAction.EditingCanceled, |
||||
message: 'user (name)', |
||||
}; |
||||
this.channel!.publish!(msg); |
||||
} |
||||
|
||||
watch(uid: string) { |
||||
const live = getGrafanaLiveSrv(); |
||||
if (!live) { |
||||
return; |
||||
} |
||||
|
||||
// Check for changes
|
||||
if (uid !== this.uid) { |
||||
this.leave(); |
||||
this.channel = live.getChannel(LiveChannelScope.Grafana, 'dashboard', uid); |
||||
this.channel.getStream().subscribe(this.observer); |
||||
this.uid = uid; |
||||
} |
||||
|
||||
console.log('Watch', uid); |
||||
} |
||||
|
||||
leave() { |
||||
if (this.channel) { |
||||
this.channel.disconnect(); |
||||
} |
||||
this.uid = undefined; |
||||
} |
||||
|
||||
ignoreNextSave() { |
||||
this.ignoreSave = true; |
||||
} |
||||
|
||||
observer = { |
||||
next: (event: LiveChannelEvent<DashboardEvent>) => { |
||||
// Send the editing state when connection starts
|
||||
if (isLiveChannelStatusEvent(event) && this.editing && event.state === LiveChannelConnectionState.Connected) { |
||||
this.sendEditingState(); |
||||
} |
||||
|
||||
if (isLiveChannelMessageEvent(event)) { |
||||
if (event.message.sessionId === sessionId) { |
||||
return; // skip internal messages
|
||||
} |
||||
|
||||
const { action } = event.message; |
||||
switch (action) { |
||||
case DashboardEventAction.EditingStarted: |
||||
case DashboardEventAction.Saved: { |
||||
if (this.ignoreSave) { |
||||
this.ignoreSave = false; |
||||
return; |
||||
} |
||||
|
||||
const dash = getDashboardSrv().getCurrent(); |
||||
if (dash.uid !== event.message.uid) { |
||||
console.log('dashboard event for differnt dashboard?', event, dash); |
||||
return; |
||||
} |
||||
|
||||
const changeTracker = getLegacyAngularInjector().get<any>('unsavedChangesSrv').tracker; |
||||
const showPopup = this.editing || changeTracker.hasChanges(); |
||||
|
||||
if (action === DashboardEventAction.Saved) { |
||||
if (showPopup) { |
||||
appEvents.emit(CoreEvents.showModalReact, { |
||||
component: DashboardChangedModal, |
||||
props: { event }, |
||||
}); |
||||
} else { |
||||
appEvents.emit(AppEvents.alertSuccess, ['Dashboard updated']); |
||||
this.reloadPage(); |
||||
} |
||||
} else if (showPopup) { |
||||
if (action === DashboardEventAction.EditingStarted) { |
||||
appEvents.emit(AppEvents.alertWarning, [ |
||||
'Another session is editing this dashboard', |
||||
event.message.message, |
||||
]); |
||||
} |
||||
} |
||||
return; |
||||
} |
||||
} |
||||
} |
||||
console.log('DashboardEvent EVENT', event); |
||||
}, |
||||
}; |
||||
|
||||
reloadPage() { |
||||
const $route = getLegacyAngularInjector().get<any>('$route'); |
||||
if ($route) { |
||||
$route.reload(); |
||||
} else { |
||||
location.reload(); |
||||
} |
||||
} |
||||
} |
||||
|
||||
export const dashboardWatcher = new DashboardWatcher(); |
||||
|
||||
export function getDashboardChannelsFeature(): CoreGrafanaLiveFeature { |
||||
const dashboardConfig: LiveChannelConfig = { |
||||
path: '${uid}', |
||||
description: 'Dashboard change events', |
||||
variables: [{ value: 'uid', label: '${uid}', description: 'unique id for a dashboard' }], |
||||
hasPresence: true, |
||||
canPublish: () => true, |
||||
}; |
||||
|
||||
return { |
||||
name: 'dashboard', |
||||
support: { |
||||
getChannelConfig: (path: string) => { |
||||
return { |
||||
...dashboardConfig, |
||||
path, // set the real path
|
||||
}; |
||||
}, |
||||
getSupportedPaths: () => [dashboardConfig], |
||||
}, |
||||
description: 'Dashboard listener', |
||||
}; |
||||
} |
||||
@ -0,0 +1,14 @@ |
||||
export enum DashboardEventAction { |
||||
Saved = 'saved', |
||||
EditingStarted = 'editing-started', // Sent when someone (who can save!) opens the editor
|
||||
EditingCanceled = 'editing-cancelled', // Sent when someone discards changes, or unsubscribes while editing
|
||||
Deleted = 'deleted', |
||||
} |
||||
|
||||
export interface DashboardEvent { |
||||
uid: string; |
||||
action: DashboardEventAction; |
||||
userId?: number; |
||||
message?: string; |
||||
sessionId?: string; |
||||
} |
||||
Loading…
Reference in new issue