Repository: vmware-tanzu/octant
Pull request #1296 (2020-08-26 05:40:20)

Bump google.golang.org/grpc from 1.31.0 to 1.31.1
Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from 1.31.0 to 1.31.1.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a href="https://github.com/grpc/grpc-go/releases">google.golang.org/grpc's releases</a>.</em></p>
<blockquote>
<h2>Release 1.31.1</h2>
<ul>
<li>eds: fix priority timeout failure when EDS removes all priorities (<a href="https://github-redirect.dependabot.com/grpc/grpc-go/issues/3839">#3839</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a href="https://github.com/grpc/grpc-go/commit/39ef2aaf62df775b9c54a59957d113a0495cae73"><code>39ef2aa</code></a> Change version to 1.31.1</li>
<li><a href="https://github.com/grpc/grpc-go/commit/644142cbf33fa50a55c5e7925804fe959ba67300"><code>644142c</code></a> eds: fix priority timeout failure when EDS removes all priorities (cherry pic...</li>
<li><a href="https://github.com/grpc/grpc-go/commit/f3c2c5ed7ed1d5673c86b69f8f7b48326330ba02"><code>f3c2c5e</code></a> Change version to 1.31.1-dev (<a href="https://github-redirect.dependabot.com/grpc/grpc-go/issues/3756">#3756</a>)</li>
<li>See full diff in <a href="https://github.com/grpc/grpc-go/compare/v1.31.0...v1.31.1">compare view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=google.golang.org/grpc&package-manager=go_modules&previous-version=1.31.0&new-version=1.31.1)](https://help.github.com/articles/configuring-automated-security-fixes)

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually
- `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)


</details>


Patch:
diff --git a/go.mod b/go.mod
index e4d54b26..a0c0400f 100644
--- a/go.mod
+++ b/go.mod
@@ -46,7 +46,7 @@ require (
 	go.uber.org/zap v1.15.0
 	golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208
 	golang.org/x/tools v0.0.0-20200716134326-a8f9df4c9543
-	google.golang.org/grpc v1.31.0
+	google.golang.org/grpc v1.31.1
 	google.golang.org/grpc/examples v0.0.0-20200707005602-4258d12073b4 // indirect
 	google.golang.org/protobuf v1.25.0
 	k8s.io/api v0.19.0-alpha.3
diff --git a/go.sum b/go.sum
index bf68453e..262e299b 100644
--- a/go.sum
+++ b/go.sum
@@ -788,8 +788,8 @@ google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8
 google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
 google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60=
 google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
-google.golang.org/grpc v1.31.0 h1:T7P4R73V3SSDPhH7WW7ATbfViLtmamH0DKrP3f9AuDI=
-google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
+google.golang.org/grpc v1.31.1 h1:SfXqXS5hkufcdZ/mHtYCh53P2b+92WQq/DZcKLgsFRs=
+google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
 google.golang.org/grpc/examples v0.0.0-20200707005602-4258d12073b4 h1:aO5Yh7ygpoceTmlzR0dl6xC4LKYmrkLcW0MBGx2uyBg=
 google.golang.org/grpc/examples v0.0.0-20200707005602-4258d12073b4/go.mod h1:5j1uub0jRGhRiSghIlrThmBUgcgLXOVJQ/l1getT4uo=
 google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
diff --git a/internal/api/fake/mock_client_manager.go b/internal/api/fake/mock_client_manager.go
index 60848892..0a871573 100644
--- a/internal/api/fake/mock_client_manager.go
+++ b/internal/api/fake/mock_client_manager.go
@@ -13,7 +13,7 @@ import (
 
 	api "github.com/vmware-tanzu/octant/internal/api"
 	config "github.com/vmware-tanzu/octant/internal/config"
-	api0 "github.com/vmware-tanzu/octant/pkg/plugin/api"
+	event "github.com/vmware-tanzu/octant/pkg/event"
 )
 
 // MockClientManager is a mock of ClientManager interface
@@ -69,10 +69,10 @@ func (mr *MockClientManagerMockRecorder) Clients() *gomock.Call {
 }
 
 // Get mocks base method
-func (m *MockClientManager) Get(arg0 string) api0.WSEventSender {
+func (m *MockClientManager) Get(arg0 string) event.WSEventSender {
 	m.ctrl.T.Helper()
 	ret := m.ctrl.Call(m, "Get", arg0)
-	ret0, _ := ret[0].(api0.WSEventSender)
+	ret0, _ := ret[0].(event.WSEventSender)
 	return ret0
 }
 
diff --git a/internal/api/websocket_client_manager.go b/internal/api/websocket_client_manager.go
index 1b2ca16f..79ca78a5 100644
--- a/internal/api/websocket_client_manager.go
+++ b/internal/api/websocket_client_manager.go
@@ -9,7 +9,7 @@ import (
 	"context"
 	"net/http"
 
-	"github.com/vmware-tanzu/octant/pkg/plugin/api"
+	"github.com/vmware-tanzu/octant/pkg/event"
 
 	"github.com/google/uuid"
 
@@ -24,7 +24,7 @@ type ClientManager interface {
 	Clients() []*WebsocketClient
 	ClientFromRequest(dashConfig config.Dash, w http.ResponseWriter, r *http.Request) (*WebsocketClient, error)
 	TemporaryClientFromLoadingRequest(w http.ResponseWriter, r *http.Request) (*WebsocketClient, error)
-	Get(id string) api.WSEventSender
+	Get(id string) event.WSEventSender
 }
 
 type clientMeta struct {
@@ -145,7 +145,7 @@ func (m *WebsocketClientManager) TemporaryClientFromLoadingRequest(w http.Respon
 	return client, nil
 }
 
-func (m *WebsocketClientManager) Get(id string) api.WSEventSender {
+func (m *WebsocketClientManager) Get(id string) event.WSEventSender {
 	for _, client := range m.Clients() {
 		if id == client.ID() {
 			return client
diff --git a/pkg/dash/dash.go b/pkg/dash/dash.go
index dd7001ae..7741251a 100644
--- a/pkg/dash/dash.go
+++ b/pkg/dash/dash.go
@@ -257,7 +257,7 @@ func (r *Runner) initAPI(ctx context.Context, logger log.Logger, options Options
 		WebsocketClientManager: r.websocketClientManager,
 	}
 
-	pluginManager, err := initPlugin(moduleManager, r.actionManager, pluginDashboardService)
+	pluginManager, err := initPlugin(moduleManager, r.actionManager, r.websocketClientManager, pluginDashboardService)
 	if err != nil {
 		return nil, nil, fmt.Errorf("initializing plugin manager: %w", err)
 	}
diff --git a/pkg/dash/plugin.go b/pkg/dash/plugin.go
index 32bb0eb7..25e3787f 100644
--- a/pkg/dash/plugin.go
+++ b/pkg/dash/plugin.go
@@ -10,17 +10,18 @@ import (
 
 	"github.com/vmware-tanzu/octant/internal/module"
 	"github.com/vmware-tanzu/octant/pkg/action"
+	"github.com/vmware-tanzu/octant/pkg/event"
 	"github.com/vmware-tanzu/octant/pkg/plugin"
 	"github.com/vmware-tanzu/octant/pkg/plugin/api"
 )
 
-func initPlugin(moduleManager module.ManagerInterface, actionManager *action.Manager, service api.Service) (*plugin.Manager, error) {
+func initPlugin(moduleManager module.ManagerInterface, actionManager *action.Manager, ws event.WSClientGetter, service api.Service) (*plugin.Manager, error) {
 	apiService, err := api.New(service)
 	if err != nil {
 		return nil, fmt.Errorf("create dashboard api: %w", err)
 	}
 
-	m := plugin.NewManager(apiService, moduleManager, actionManager)
+	m := plugin.NewManager(apiService, moduleManager, actionManager, ws)
 
 	pluginList, err := plugin.AvailablePlugins(plugin.DefaultConfig)
 	if err != nil {
diff --git a/pkg/event/event.go b/pkg/event/event.go
index cd5bd5cd..769ccadc 100644
--- a/pkg/event/event.go
+++ b/pkg/event/event.go
@@ -5,6 +5,8 @@ SPDX-License-Identifier: Apache-2.0
 
 package event
 
+//go:generate mockgen -destination=./fake/mock_event.go -package=fake github.com/vmware-tanzu/octant/pkg/event WSClientGetter,WSEventSender
+
 import (
 	"fmt"
 
@@ -93,3 +95,11 @@ func CreateEvent(eventType EventType, fields action.Payload) Event {
 		Data: fields,
 	}
 }
+
+type WSClientGetter interface {
+	Get(id string) WSEventSender
+}
+
+type WSEventSender interface {
+	Send(event Event)
+}
diff --git a/pkg/event/fake/mock_event.go b/pkg/event/fake/mock_event.go
new file mode 100644
index 00000000..dcfc1d8b
--- /dev/null
+++ b/pkg/event/fake/mock_event.go
@@ -0,0 +1,85 @@
+// Code generated by MockGen. DO NOT EDIT.
+// Source: github.com/vmware-tanzu/octant/pkg/event (interfaces: WSClientGetter,WSEventSender)
+
+// Package fake is a generated GoMock package.
+package fake
+
+import (
+	reflect "reflect"
+
+	gomock "github.com/golang/mock/gomock"
+
+	event "github.com/vmware-tanzu/octant/pkg/event"
+)
+
+// MockWSClientGetter is a mock of WSClientGetter interface
+type MockWSClientGetter struct {
+	ctrl     *gomock.Controller
+	recorder *MockWSClientGetterMockRecorder
+}
+
+// MockWSClientGetterMockRecorder is the mock recorder for MockWSClientGetter
+type MockWSClientGetterMockRecorder struct {
+	mock *MockWSClientGetter
+}
+
+// NewMockWSClientGetter creates a new mock instance
+func NewMockWSClientGetter(ctrl *gomock.Controller) *MockWSClientGetter {
+	mock := &MockWSClientGetter{ctrl: ctrl}
+	mock.recorder = &MockWSClientGetterMockRecorder{mock}
+	return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use
+func (m *MockWSClientGetter) EXPECT() *MockWSClientGetterMockRecorder {
+	return m.recorder
+}
+
+// Get mocks base method
+func (m *MockWSClientGetter) Get(arg0 string) event.WSEventSender {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "Get", arg0)
+	ret0, _ := ret[0].(event.WSEventSender)
+	return ret0
+}
+
+// Get indicates an expected call of Get
+func (mr *MockWSClientGetterMockRecorder) Get(arg0 interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockWSClientGetter)(nil).Get), arg0)
+}
+
+// MockWSEventSender is a mock of WSEventSender interface
+type MockWSEventSender struct {
+	ctrl     *gomock.Controller
+	recorder *MockWSEventSenderMockRecorder
+}
+
+// MockWSEventSenderMockRecorder is the mock recorder for MockWSEventSender
+type MockWSEventSenderMockRecorder struct {
+	mock *MockWSEventSender
+}
+
+// NewMockWSEventSender creates a new mock instance
+func NewMockWSEventSender(ctrl *gomock.Controller) *MockWSEventSender {
+	mock := &MockWSEventSender{ctrl: ctrl}
+	mock.recorder = &MockWSEventSenderMockRecorder{mock}
+	return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use
+func (m *MockWSEventSender) EXPECT() *MockWSEventSenderMockRecorder {
+	return m.recorder
+}
+
+// Send mocks base method
+func (m *MockWSEventSender) Send(arg0 event.Event) {
+	m.ctrl.T.Helper()
+	m.ctrl.Call(m, "Send", arg0)
+}
+
+// Send indicates an expected call of Send
+func (mr *MockWSEventSenderMockRecorder) Send(arg0 interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Send", reflect.TypeOf((*MockWSEventSender)(nil).Send), arg0)
+}
diff --git a/pkg/plugin/api/server.go b/pkg/plugin/api/server.go
index 563ecdcf..d68c0b32 100644
--- a/pkg/plugin/api/server.go
+++ b/pkg/plugin/api/server.go
@@ -79,19 +79,11 @@ type GRPCService struct {
 	PortForwarder          portforward.PortForwarder
 	FrontendProxy          FrontendProxy
 	NamespaceInterface     cluster.NamespaceInterface
-	WebsocketClientManager WSClientGetter
+	WebsocketClientManager event.WSClientGetter
 }
 
 var _ Service = (*GRPCService)(nil)
 
-type WSClientGetter interface {
-	Get(id string) WSEventSender
-}
-
-type WSEventSender interface {
-	Send(event event.Event)
-}
-
 // List lists objects.
 func (s *GRPCService) List(ctx context.Context, key store.Key) (*unstructured.UnstructuredList, error) {
 	// TODO: support hasSynced
diff --git a/pkg/plugin/javascript.go b/pkg/plugin/javascript.go
index 469603b7..1998f8ae 100644
--- a/pkg/plugin/javascript.go
+++ b/pkg/plugin/javascript.go
@@ -13,6 +13,8 @@ import (
 	"path"
 	"sync"
 
+	ocontext "github.com/vmware-tanzu/octant/internal/context"
+
 	"github.com/dop251/goja"
 	"github.com/dop251/goja_nodejs/eventloop"
 	"k8s.io/apimachinery/pkg/runtime"
@@ -226,7 +228,7 @@ func (t *jsPlugin) Navigation(_ context.Context) (navigation.Navigation, error)
 }
 
 // Content returns the content response for a JavaScript plugin acting as a module.
-func (t *jsPlugin) Content(_ context.Context, contentPath string) (component.ContentResponse, error) {
+func (t *jsPlugin) Content(ctx context.Context, contentPath string) (component.ContentResponse, error) {
 	t.mu.Lock()
 	defer t.mu.Unlock()
 
@@ -234,6 +236,8 @@ func (t *jsPlugin) Content(_ context.Context, contentPath string) (component.Con
 	errCh := make(chan error)
 
 	t.loop.RunOnLoop(func(vm *goja.Runtime) {
+		clientID := ocontext.WebsocketClientIDFrom(ctx)
+
 		handler, err := vm.RunString("_concretePlugin.contentHandler")
 		if err != nil {
 			errCh <- fmt.Errorf("unable to load contentHandler from plugin: %w", err)
@@ -250,6 +254,9 @@ func (t *jsPlugin) Content(_ context.Context, contentPath string) (component.Con
 			errCh <- fmt.Errorf("unable to set contentPath: %w", err)
 			return
 		}
+		if err := obj.Set("clientID", vm.ToValue(clientID)); err != nil {
+			errCh <- fmt.Errorf("unable to set clientID: %w", err)
+		}
 		s, err := cHandler(t.pluginClass, obj)
 		if err != nil {
 			errCh <- fmt.Errorf("calling contentHandler: %w", err)
@@ -354,11 +361,11 @@ func (t *jsPlugin) Register(_ context.Context, _ string) (Metadata, error) {
 }
 
 // PrintTab returns the tab response from a JavaScript plugins tab handler.
-func (t *jsPlugin) PrintTab(_ context.Context, object runtime.Object) (TabResponse, error) {
+func (t *jsPlugin) PrintTab(ctx context.Context, object runtime.Object) (TabResponse, error) {
 	t.mu.Lock()
 	defer t.mu.Unlock()
 
-	tabResponse, err := t.objectRequestCall("tabHandler", object)
+	tabResponse, err := t.objectRequestCall(ctx, "tabHandler", object)
 	if err != nil {
 		return TabResponse{}, err
 	}
@@ -393,11 +400,11 @@ func (t *jsPlugin) PrintTab(_ context.Context, object runtime.Object) (TabRespon
 }
 
 // ObjectStats returns the object status from a JavaScript plugins object status handler.
-func (t *jsPlugin) ObjectStatus(_ context.Context, object runtime.Object) (ObjectStatusResponse, error) {
+func (t *jsPlugin) ObjectStatus(ctx context.Context, object runtime.Object) (ObjectStatusResponse, error) {
 	t.mu.Lock()
 	defer t.mu.Unlock()
 
-	osResponse, err := t.objectRequestCall("objectStatusHandler", object)
+	osResponse, err := t.objectRequestCall(ctx, "objectStatusHandler", object)
 	if err != nil {
 		return ObjectStatusResponse{}, err
 	}
@@ -428,13 +435,15 @@ func (t *jsPlugin) ObjectStatus(_ context.Context, object runtime.Object) (Objec
 }
 
 // HandleAction calls the JavaScript plugins action handler.
-func (t *jsPlugin) HandleAction(_ context.Context, actionPath string, payload action.Payload) error {
+func (t *jsPlugin) HandleAction(ctx context.Context, actionPath string, payload action.Payload) error {
 	t.mu.Lock()
 	defer t.mu.Unlock()
 
 	errCh := make(chan error)
 
 	t.loop.RunOnLoop(func(vm *goja.Runtime) {
+		clientID := ocontext.WebsocketClientIDFrom(ctx)
+
 		handler, err := vm.RunString("_concretePlugin.actionHandler")
 		if err != nil {
 			errCh <- fmt.Errorf("unable to load actionHandler from plugin: %w", err)
@@ -459,6 +468,10 @@ func (t *jsPlugin) HandleAction(_ context.Context, actionPath string, payload ac
 			errCh <- fmt.Errorf("unable to set payload: %w", err)
 			return
 		}
+		if err := obj.Set("clientID", clientID); err != nil {
+			errCh <- fmt.Errorf("unable to set clientID: %w", err)
+			return
+		}
 
 		s, err := cHandler(t.pluginClass, obj)
 		if err != nil {
@@ -486,11 +499,11 @@ func (t *jsPlugin) HandleAction(_ context.Context, actionPath string, payload ac
 }
 
 // Print returns the print response from the JavaScript plugins print handler.
-func (t *jsPlugin) Print(_ context.Context, object runtime.Object) (PrintResponse, error) {
+func (t *jsPlugin) Print(ctx context.Context, object runtime.Object) (PrintResponse, error) {
 	t.mu.Lock()
 	defer t.mu.Unlock()
 
-	printResponse, err := t.objectRequestCall("printHandler", object)
+	printResponse, err := t.objectRequestCall(ctx, "printHandler", object)
 	if err != nil {
 		return PrintResponse{}, err
 	}
@@ -537,11 +550,13 @@ func (t *jsPlugin) Print(_ context.Context, object runtime.Object) (PrintRespons
 	return response, nil
 }
 
-func (t *jsPlugin) objectRequestCall(handlerName string, object runtime.Object) (*goja.Object, error) {
+func (t *jsPlugin) objectRequestCall(ctx context.Context, handlerName string, object runtime.Object) (*goja.Object, error) {
 	errCh := make(chan error)
 	var response *goja.Object
 
 	t.loop.RunOnLoop(func(vm *goja.Runtime) {
+		clientID := ocontext.WebsocketClientIDFrom(ctx)
+
 		handler, err := vm.RunString(fmt.Sprintf("_concretePlugin.%s", handlerName))
 		if err != nil {
 			errCh <- fmt.Errorf("unable to load %s from plugin: %w", handlerName, err)
@@ -559,6 +574,10 @@ func (t *jsPlugin) objectRequestCall(handlerName string, object runtime.Object)
 			errCh <- fmt.Errorf("unable to set object: %w", err)
 			return
 		}
+		if err := obj.Set("clientID", vm.ToValue(clientID)); err != nil {
+			errCh <- fmt.Errorf("unable to set clientID: %w", err)
+			return
+		}
 		s, err := cHandler(t.pluginClass, obj)
 		if err != nil {
 			errCh <- err
diff --git a/pkg/plugin/javascript/dashboard_client.go b/pkg/plugin/javascript/dashboard_client.go
index 3bd5ff9e..07030bf5 100644
--- a/pkg/plugin/javascript/dashboard_client.go
+++ b/pkg/plugin/javascript/dashboard_client.go
@@ -9,6 +9,8 @@ import (
 	"context"
 	"fmt"
 
+	"github.com/vmware-tanzu/octant/pkg/event"
+
 	"github.com/dop251/goja"
 
 	"github.com/vmware-tanzu/octant/internal/octant"
@@ -50,13 +52,14 @@ type OctantClient interface {
 }
 
 // DefaultFunctions are the default functions for the ModularDashboardClientFactory.
-func DefaultFunctions(octantClient OctantClient) []octant.DashboardClientFunction {
+func DefaultFunctions(octantClient OctantClient, wsClient event.WSClientGetter) []octant.DashboardClientFunction {
 	return []octant.DashboardClientFunction{
 		NewDashboardGet(octantClient),
 		NewDashboardList(octantClient),
 		NewDashboardUpdate(octantClient),
 		NewDashboardDelete(octantClient),
 		NewDashboardRefPath(octantClient),
+		NewDashboardSendEvent(wsClient),
 	}
 }
 
diff --git a/pkg/plugin/javascript/dashboard_send_event.go b/pkg/plugin/javascript/dashboard_send_event.go
new file mode 100644
index 00000000..b5b787ca
--- /dev/null
+++ b/pkg/plugin/javascript/dashboard_send_event.go
@@ -0,0 +1,76 @@
+/*
+ * Copyright (c) 2020 the Octant contributors. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package javascript
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/dop251/goja"
+
+	ocontext "github.com/vmware-tanzu/octant/internal/context"
+	"github.com/vmware-tanzu/octant/internal/octant"
+	"github.com/vmware-tanzu/octant/pkg/action"
+	"github.com/vmware-tanzu/octant/pkg/event"
+)
+
+// DashboardList is a function that lists objects by key.
+type DashboardSendEvent struct {
+	WebsocketClientManager event.WSClientGetter
+}
+
+var _ octant.DashboardClientFunction = &DashboardSendEvent{}
+
+// NewDashboardSendEvent creates an instance of DashboardSendEvent.
+func NewDashboardSendEvent(websocketClientManager event.WSClientGetter) *DashboardSendEvent {
+	d := &DashboardSendEvent{
+		WebsocketClientManager: websocketClientManager,
+	}
+	return d
+}
+
+// Name returns the name of this function. It will always return "SendEvent".
+func (d *DashboardSendEvent) Name() string {
+	return "SendEvent"
+}
+
+// Call creates a function call that sends an event to a websocket client. If no ClientID
+// is provided or the clientID cannot be found a javascript exception is raised.
+func (d *DashboardSendEvent) Call(ctx context.Context, vm *goja.Runtime) func(c goja.FunctionCall) goja.Value {
+	// clientID string, event EventType, payload action.Payload
+	return func(c goja.FunctionCall) goja.Value {
+		clientID := c.Argument(0).String()
+		if clientID == "" {
+			panic(panicMessage(vm, fmt.Errorf("clientID is empty"), ""))
+		}
+
+		eventType := event.EventType(c.Argument(1).String())
+		if eventType == "" {
+			panic(panicMessage(vm, fmt.Errorf("eventType is empty"), ""))
+		}
+
+		var payload action.Payload
+		obj := c.Argument(2).ToObject(vm)
+
+		// This will never error since &key is a pointer to a type.
+		_ = vm.ExportTo(obj, &payload)
+
+		event := event.CreateEvent(eventType, payload)
+
+		if d.WebsocketClientManager == nil {
+			panic(panicMessage(vm, fmt.Errorf("websocket client manager is nil"), ""))
+		}
+
+		sender := d.WebsocketClientManager.Get(clientID)
+		if sender == nil {
+			panic(panicMessage(vm, fmt.Errorf("unable to find ws client %s", ocontext.WebsocketClientIDFrom(ctx)), ""))
+		}
+
+		sender.Send(event)
+
+		return goja.Undefined()
+	}
+}
diff --git a/pkg/plugin/manager.go b/pkg/plugin/manager.go
index 502c6de4..213aa3bc 100644
--- a/pkg/plugin/manager.go
+++ b/pkg/plugin/manager.go
@@ -18,6 +18,8 @@ import (
 	"sync"
 	"time"
 
+	"github.com/vmware-tanzu/octant/pkg/event"
+
 	"k8s.io/apimachinery/pkg/runtime/schema"
 	"k8s.io/client-go/dynamic"
 
@@ -269,6 +271,7 @@ type Manager struct {
 	ClientFactory   ClientFactory
 	ModuleRegistrar ModuleRegistrar
 	ActionRegistrar ActionRegistrar
+	WSClient        event.WSClientGetter
 
 	Runners Runners
 
@@ -282,7 +285,7 @@ type Manager struct {
 var _ ManagerInterface = (*Manager)(nil)
 
 // NewManager creates an instance of Manager.
-func NewManager(apiService api.API, moduleRegistrar ModuleRegistrar, actionRegistrar ActionRegistrar, options ...ManagerOption) *Manager {
+func NewManager(apiService api.API, moduleRegistrar ModuleRegistrar, actionRegistrar ActionRegistrar, ws event.WSClientGetter, options ...ManagerOption) *Manager {
 	m := &Manager{
 		store:           NewDefaultStore(),
 		ClientFactory:   NewDefaultClientFactory(),
@@ -290,6 +293,7 @@ func NewManager(apiService api.API, moduleRegistrar ModuleRegistrar, actionRegis
 		API:             apiService,
 		ModuleRegistrar: moduleRegistrar,
 		ActionRegistrar: actionRegistrar,
+		WSClient:        ws,
 	}
 
 	for _, option := range options {
@@ -445,7 +449,7 @@ func (m *Manager) unregisterJSPlugin(_ context.Context, p JSPlugin) error {
 }
 
 func (m *Manager) registerJSPlugin(ctx context.Context, pluginPath string) error {
-	dashboardClientFactory := javascript.NewModularDashboardClientFactory(javascript.DefaultFunctions(m.octantClient))
+	dashboardClientFactory := javascript.NewModularDashboardClientFactory(javascript.DefaultFunctions(m.octantClient, m.WSClient))
 
 	jsPlugin, err := NewJSPlugin(ctx, pluginPath, dashboardClientFactory)
 	if err != nil {
diff --git a/vendor/google.golang.org/grpc/version.go b/vendor/google.golang.org/grpc/version.go
index fb878dda..de387f7b 100644
--- a/vendor/google.golang.org/grpc/version.go
+++ b/vendor/google.golang.org/grpc/version.go
@@ -19,4 +19,4 @@
 package grpc
 
 // Version is the current grpc version.
-const Version = "1.31.0"
+const Version = "1.31.1"
diff --git a/vendor/modules.txt b/vendor/modules.txt
index 364236e4..26c00e29 100644
--- a/vendor/modules.txt
+++ b/vendor/modules.txt
@@ -357,7 +357,7 @@ google.golang.org/appengine/internal/urlfetch
 google.golang.org/appengine/urlfetch
 # google.golang.org/genproto v0.0.0-20200624020401-64a14ca9d1ad
 google.golang.org/genproto/googleapis/rpc/status
-# google.golang.org/grpc v1.31.0
+# google.golang.org/grpc v1.31.1
 ## explicit
 google.golang.org/grpc
 google.golang.org/grpc/attributes
diff --git a/web/src/app/modules/shared/components/presentation/link/link.component.html b/web/src/app/modules/shared/components/presentation/link/link.component.html
index 125d6662..9eec1330 100644
--- a/web/src/app/modules/shared/components/presentation/link/link.component.html
+++ b/web/src/app/modules/shared/components/presentation/link/link.component.html
@@ -1,11 +1,8 @@
-<ng-template [ngIf]="isAbsolute" [ngIfElse]="relative">
-  <ng-container *ngIf="hasStatus">
-    <app-indicator [status]="view.config.status"></app-indicator>
-  </ng-container>
-  <a [routerLink]="ref">{{ value }}</a>
+<ng-template [ngIf]="isExternal" [ngIfElse]="internal">
+  <a href="{{ ref }}">{{ value }}</a>
 </ng-template>
 
-<ng-template #relative>
+<ng-template #internal>
   <ng-container *ngIf="hasStatus">
     <app-indicator
       [status]="view.config.status"
diff --git a/web/src/app/modules/shared/components/presentation/link/link.component.ts b/web/src/app/modules/shared/components/presentation/link/link.component.ts
index 91645894..fdb6a97f 100644
--- a/web/src/app/modules/shared/components/presentation/link/link.component.ts
+++ b/web/src/app/modules/shared/components/presentation/link/link.component.ts
@@ -6,7 +6,7 @@ import { Component } from '@angular/core';
 import { LinkView } from 'src/app/modules/shared/models/content';
 import { AbstractViewComponent } from '../../abstract-view/abstract-view.component';
 
-const isUrlAbsolute = url =>
+const isUrlExternal = url =>
   url?.indexOf('://') > 0 || url?.indexOf('//') === 0;
 
 @Component({
@@ -17,7 +17,7 @@ const isUrlAbsolute = url =>
 export class LinkComponent extends AbstractViewComponent<LinkView> {
   ref: string;
   value: string;
-  isAbsolute: boolean;
+  isExternal: boolean;
   hasStatus: boolean;
 
   constructor() {
@@ -28,7 +28,7 @@ export class LinkComponent extends AbstractViewComponent<LinkView> {
     const view = this.v;
     this.ref = view.config.ref;
     this.value = view.config.value;
-    this.isAbsolute = isUrlAbsolute(this.ref);
+    this.isExternal = isUrlExternal(this.ref);
 
     if (view.config.status) {
       this.hasStatus = true;


Test patch:
diff --git a/internal/describer/object_test.go b/internal/describer/object_test.go
index cdcde405..a5e4d1c5 100644
--- a/internal/describer/object_test.go
+++ b/internal/describer/object_test.go
@@ -9,6 +9,8 @@ import (
 	"context"
 	"testing"
 
+	"github.com/vmware-tanzu/octant/pkg/event/fake"
+
 	"k8s.io/apimachinery/pkg/runtime"
 
 	"github.com/vmware-tanzu/octant/internal/describer"
@@ -45,8 +47,9 @@ func TestObjectDescriber(t *testing.T) {
 	dashConfig := configFake.NewMockDash(controller)
 	moduleRegistrar := pluginFake.NewMockModuleRegistrar(controller)
 	actionRegistrar := pluginFake.NewMockActionRegistrar(controller)
+	wsClient := fake.NewMockWSClientGetter(controller)
 
-	pluginManager := plugin.NewManager(nil, moduleRegistrar, actionRegistrar)
+	pluginManager := plugin.NewManager(nil, moduleRegistrar, actionRegistrar, wsClient)
 	dashConfig.EXPECT().PluginManager().Return(pluginManager).AnyTimes()
 
 	podSummary := component.NewText("summary")
diff --git a/pkg/plugin/javascript/dashboard_send_event_test.go b/pkg/plugin/javascript/dashboard_send_event_test.go
new file mode 100644
index 00000000..96c7c3ba
--- /dev/null
+++ b/pkg/plugin/javascript/dashboard_send_event_test.go
@@ -0,0 +1,88 @@
+/*
+ * Copyright (c) 2020 the Octant contributors. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package javascript
+
+import (
+	"context"
+	"testing"
+
+	"github.com/vmware-tanzu/octant/pkg/action"
+
+	"github.com/vmware-tanzu/octant/pkg/event"
+
+	"github.com/golang/mock/gomock"
+	"github.com/stretchr/testify/require"
+
+	"github.com/vmware-tanzu/octant/pkg/event/fake"
+)
+
+func TestDashboardSendEvent_Name(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	defer ctrl.Finish()
+	wsClient := fake.NewMockWSClientGetter(ctrl)
+
+	d := NewDashboardSendEvent(wsClient)
+
+	want := "SendEvent"
+	got := d.Name()
+
+	require.Equal(t, want, got)
+}
+
+func TestDashboardSendEvent_Call(t *testing.T) {
+	type ctorArgs struct {
+		wsClient func(ctx context.Context, ctrl *gomock.Controller) event.WSClientGetter
+	}
+	tests := []struct {
+		name     string
+		ctorArgs ctorArgs
+		call     string
+		wantErr  bool
+	}{
+		{
+			name: "empty clientID",
+			ctorArgs: ctorArgs{
+				wsClient: func(ctx context.Context, ctrl *gomock.Controller) event.WSClientGetter {
+					wsClient := fake.NewMockWSClientGetter(ctrl)
+					return wsClient
+				},
+			},
+			call:    `dashClient.SendEvent("", "event.octant.dev/alert", {namespace:'test', apiVersion: 'v1', kind:'Pod'})`,
+			wantErr: true,
+		},
+		{
+			name: "action sends",
+			ctorArgs: ctorArgs{
+				wsClient: func(ctx context.Context, ctrl *gomock.Controller) event.WSClientGetter {
+					event := event.CreateEvent(event.EventTypeAlert, action.Payload{"namespace": "test", "apiVersion": "v1", "kind": "ReplicaSet"})
+
+					wsClient := fake.NewMockWSClientGetter(ctrl)
+					wsEventSender := fake.NewMockWSEventSender(ctrl)
+
+					wsClient.EXPECT().Get("my-client-id").Return(wsEventSender)
+					wsEventSender.EXPECT().Send(event)
+					return wsClient
+				},
+			},
+			call:    `dashClient.SendEvent("my-client-id", "event.octant.dev/alert", {namespace:'test', apiVersion: 'v1', kind:'ReplicaSet'})`,
+			wantErr: false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			ctrl := gomock.NewController(t)
+			defer ctrl.Finish()
+
+			ctx := context.Background()
+			d := NewDashboardSendEvent(tt.ctorArgs.wsClient(ctx, ctrl))
+
+			runner := functionRunner{wantErr: tt.wantErr}
+			runner.run(ctx, t, d, tt.call)
+
+		})
+	}
+}
diff --git a/pkg/plugin/manager_test.go b/pkg/plugin/manager_test.go
index 7da23348..09ee0e6e 100644
--- a/pkg/plugin/manager_test.go
+++ b/pkg/plugin/manager_test.go
@@ -9,6 +9,8 @@ import (
 	"context"
 	"testing"
 
+	fake2 "github.com/vmware-tanzu/octant/pkg/event/fake"
+
 	"github.com/golang/mock/gomock"
 	"github.com/hashicorp/go-plugin"
 	"github.com/stretchr/testify/assert"
@@ -59,6 +61,7 @@ func TestManager(t *testing.T) {
 	clientFactory := fake.NewMockClientFactory(controller)
 	moduleRegistrar := fake.NewMockModuleRegistrar(controller)
 	actionRegistrar := fake.NewMockActionRegistrar(controller)
+	wsClient := fake2.NewMockWSClientGetter(controller)
 
 	name := "plugin1"
 
@@ -76,7 +79,7 @@ func TestManager(t *testing.T) {
 	})
 
 	apiService := &stubAPIService{}
-	manager := dashPlugin.NewManager(apiService, moduleRegistrar, actionRegistrar, options...)
+	manager := dashPlugin.NewManager(apiService, moduleRegistrar, actionRegistrar, wsClient, options...)
 
 	manager.SetStore(store)
 
@@ -101,6 +104,7 @@ func TestManager_Print(t *testing.T) {
 	store := fake.NewMockManagerStore(controller)
 	moduleRegistrar := fake.NewMockModuleRegistrar(controller)
 	actionRegistrar := fake.NewMockActionRegistrar(controller)
+	wsClient := fake2.NewMockWSClientGetter(controller)
 
 	store.EXPECT().ClientNames().Return([]string{"plugin1", "plugin2"})
 
@@ -131,7 +135,7 @@ func TestManager_Print(t *testing.T) {
 	})
 
 	apiService := &stubAPIService{}
-	manager := dashPlugin.NewManager(apiService, moduleRegistrar, actionRegistrar, options...)
+	manager := dashPlugin.NewManager(apiService, moduleRegistrar, actionRegistrar, wsClient, options...)
 	manager.SetStore(store)
 
 	ctx := context.Background()
@@ -158,6 +162,7 @@ func TestManager_Tabs(t *testing.T) {
 	store := fake.NewMockManagerStore(controller)
 	moduleRegistrar := fake.NewMockModuleRegistrar(controller)
 	actionRegistrar := fake.NewMockActionRegistrar(controller)
+	wsClient := fake2.NewMockWSClientGetter(controller)
 
 	store.EXPECT().ClientNames().Return([]string{"plugin1", "plugin2"})
 
@@ -179,7 +184,7 @@ func TestManager_Tabs(t *testing.T) {
 	})
 
 	apiService := &stubAPIService{}
-	manager := dashPlugin.NewManager(apiService, moduleRegistrar, actionRegistrar, options...)
+	manager := dashPlugin.NewManager(apiService, moduleRegistrar, actionRegistrar, wsClient, options...)
 	manager.SetStore(store)
 
 	ctx := context.Background()