From 8af859751565eadc64b2737f4671840df8172a8d Mon Sep 17 00:00:00 2001 From: Malcolm Holmes Date: Fri, 12 Apr 2024 13:49:20 +0100 Subject: [PATCH 01/11] Working livereload, first pass at watch --- cmd/grr/config.go | 1 + cmd/grr/workflow.go | 3 +- pkg/grafana/dashboard-handler.go | 18 +++--- pkg/grizzly/handler.go | 10 ++- pkg/grizzly/livereload/connection.go | 73 +++++++++++++++++++++ pkg/grizzly/livereload/hub.go | 64 ++++++++++++++++++ pkg/grizzly/livereload/livereload.go | 63 ++++++++++++++++++ pkg/grizzly/livereload/livereload.js | 1 + pkg/grizzly/resources.go | 10 +++ pkg/grizzly/server.go | 97 +++++++++++++++++++++++++--- pkg/grizzly/workflow.go | 4 +- 11 files changed, 321 insertions(+), 23 deletions(-) create mode 100644 pkg/grizzly/livereload/connection.go create mode 100644 pkg/grizzly/livereload/hub.go create mode 100644 pkg/grizzly/livereload/livereload.go create mode 100644 pkg/grizzly/livereload/livereload.js diff --git a/cmd/grr/config.go b/cmd/grr/config.go index 0773df36..434099d2 100644 --- a/cmd/grr/config.go +++ b/cmd/grr/config.go @@ -32,6 +32,7 @@ type Opts struct { OpenBrowser bool ProxyPort int CanSave bool + Watch bool } func configPathCmd() *cli.Command { diff --git a/cmd/grr/workflow.go b/cmd/grr/workflow.go index 3e520583..feb526e2 100644 --- a/cmd/grr/workflow.go +++ b/cmd/grr/workflow.go @@ -414,8 +414,9 @@ func serveCmd(registry grizzly.Registry) *cli.Command { return err } - return grizzly.Serve(registry, parser, parserOpts, resourcesPath, opts.ProxyPort, opts.OpenBrowser, onlySpec, format) + return grizzly.Serve(registry, parser, parserOpts, resourcesPath, opts.ProxyPort, opts.OpenBrowser, opts.Watch, onlySpec, format) } + cmd.Flags().BoolVarP(&opts.Watch, "watch", "w", false, "Watch filesystem for changes") cmd.Flags().BoolVarP(&opts.OpenBrowser, "open-browser", "b", false, "Open Grizzly in default browser") cmd.Flags().IntVarP(&opts.ProxyPort, "port", "p", 8080, "Port on which the server will listen") cmd = initialiseOnlySpec(cmd, &opts) diff --git a/pkg/grafana/dashboard-handler.go b/pkg/grafana/dashboard-handler.go index f9229fdb..e941b042 100644 --- a/pkg/grafana/dashboard-handler.go +++ b/pkg/grafana/dashboard-handler.go @@ -256,27 +256,27 @@ func (h *DashboardHandler) Detect(data map[string]any) bool { return true } -func (h *DashboardHandler) GetProxyEndpoints(p grizzly.Server) []grizzly.ProxyEndpoint { - return []grizzly.ProxyEndpoint{ +func (h *DashboardHandler) GetProxyEndpoints(p grizzly.Server, config grizzly.HttpEndpointConfig) []grizzly.HttpEndpoint { + return []grizzly.HttpEndpoint{ { Method: "GET", URL: "/d/{uid}/{slug}", - Handler: h.resourceFromQueryParameterMiddleware(p, "grizzly_from_file", h.RootDashboardPageHandler(p)), + Handler: h.resourceFromQueryParameterMiddleware(p, "grizzly_from_file", h.RootDashboardPageHandler(p, config)), }, { Method: "GET", URL: "/api/dashboards/uid/{uid}", - Handler: h.DashboardJSONGetHandler(p), + Handler: h.DashboardJSONGetHandler(p, config), }, { Method: "POST", URL: "/api/dashboards/db", - Handler: h.DashboardJSONPostHandler(p), + Handler: h.DashboardJSONPostHandler(p, config), }, { Method: "POST", URL: "/api/dashboards/db/", - Handler: h.DashboardJSONPostHandler(p), + Handler: h.DashboardJSONPostHandler(p, config), }, } } @@ -294,7 +294,7 @@ func (h *DashboardHandler) resourceFromQueryParameterMiddleware(p grizzly.Server } } -func (h *DashboardHandler) RootDashboardPageHandler(p grizzly.Server) http.HandlerFunc { +func (h *DashboardHandler) RootDashboardPageHandler(p grizzly.Server, endpointConfig grizzly.HttpEndpointConfig) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Add("Content-Type", "text/html") config := h.Provider.(ClientProvider).Config() @@ -335,7 +335,7 @@ func (h *DashboardHandler) RootDashboardPageHandler(p grizzly.Server) http.Handl } } -func (h *DashboardHandler) DashboardJSONGetHandler(p grizzly.Server) http.HandlerFunc { +func (h *DashboardHandler) DashboardJSONGetHandler(p grizzly.Server, config grizzly.HttpEndpointConfig) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { uid := chi.URLParam(r, "uid") if uid == "" { @@ -368,7 +368,7 @@ func (h *DashboardHandler) DashboardJSONGetHandler(p grizzly.Server) http.Handle } } -func (h *DashboardHandler) DashboardJSONPostHandler(p grizzly.Server) http.HandlerFunc { +func (h *DashboardHandler) DashboardJSONPostHandler(p grizzly.Server, config grizzly.HttpEndpointConfig) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { resp := struct { Dashboard map[string]any `json:"dashboard"` diff --git a/pkg/grizzly/handler.go b/pkg/grizzly/handler.go index 3aedcb57..096a5576 100644 --- a/pkg/grizzly/handler.go +++ b/pkg/grizzly/handler.go @@ -112,16 +112,20 @@ type ListenHandler interface { Listen(UID, filename string) error } -type ProxyEndpoint struct { +type HttpEndpointConfig struct { + Port int +} + +type HttpEndpoint struct { Method string URL string - Handler func(http.ResponseWriter, *http.Request) + Handler http.HandlerFunc } // ProxyHandler describes a handler that can be used to edit resources live via a proxied UI type ProxyHandler interface { // RegisterHandlers registers HTTP handlers for proxy events - GetProxyEndpoints(p Server) []ProxyEndpoint + GetProxyEndpoints(p Server, config HttpEndpointConfig) []HttpEndpoint // ProxyURL returns a URL path for a resource on the proxy ProxyURL(Resource) (string, error) diff --git a/pkg/grizzly/livereload/connection.go b/pkg/grizzly/livereload/connection.go new file mode 100644 index 00000000..9a987e37 --- /dev/null +++ b/pkg/grizzly/livereload/connection.go @@ -0,0 +1,73 @@ +// Copyright 2015 The Hugo Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package livereload + +import ( + "bytes" + "sync" + + "github.com/gorilla/websocket" +) + +type connection struct { + // The websocket connection. + ws *websocket.Conn + + // Buffered channel of outbound messages. + send chan []byte + + // There is a potential data race, especially visible with large files. + // This is protected by synchronization of the send channel's close. + closer sync.Once +} + +func NewConnection(send chan []byte, ws *websocket.Conn) *connection { + return &connection{ + send: send, + ws: ws, + } +} + +func (c *connection) close() { + c.closer.Do(func() { + close(c.send) + }) +} + +func (c *connection) reader() { + for { + _, message, err := c.ws.ReadMessage() + if err != nil { + break + } + if bytes.Contains(message, []byte(`"command":"hello"`)) { + c.send <- []byte(`{ + "command": "hello", + "protocols": [ "http://livereload.com/protocols/official-7" ], + "serverName": "Hugo" + }`) + } + } + c.ws.Close() +} + +func (c *connection) writer() { + for message := range c.send { + err := c.ws.WriteMessage(websocket.TextMessage, message) + if err != nil { + break + } + } + c.ws.Close() +} diff --git a/pkg/grizzly/livereload/hub.go b/pkg/grizzly/livereload/hub.go new file mode 100644 index 00000000..6b337414 --- /dev/null +++ b/pkg/grizzly/livereload/hub.go @@ -0,0 +1,64 @@ +// Copyright 2015 The Hugo Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package livereload + +type hub struct { + // Registered connections. + connections map[*connection]bool + + // Inbound messages from the connections. + broadcast chan []byte + + // Register requests from the connections. + register chan *connection + + // Unregister requests from connections. + unregister chan *connection +} + +var wsHub = hub{ + broadcast: make(chan []byte), + register: make(chan *connection), + unregister: make(chan *connection), + connections: make(map[*connection]bool), +} + +func Register(c *connection) { + wsHub.register <- c +} + +func Unregister(c *connection) { + wsHub.unregister <- c +} + +func (h *hub) run() { + for { + select { + case c := <-h.register: + h.connections[c] = true + case c := <-h.unregister: + delete(h.connections, c) + c.close() + case m := <-h.broadcast: + for c := range h.connections { + select { + case c.send <- m: + default: + delete(h.connections, c) + c.close() + } + } + } + } +} diff --git a/pkg/grizzly/livereload/livereload.go b/pkg/grizzly/livereload/livereload.go new file mode 100644 index 00000000..fb7a2c0e --- /dev/null +++ b/pkg/grizzly/livereload/livereload.go @@ -0,0 +1,63 @@ +package livereload + +import ( + _ "embed" + "fmt" + "log" + "net/http" + "strings" + "time" + + "github.com/gorilla/websocket" +) + +// Handler is a HandlerFunc handling the livereload +// Websocket interaction. +func LiveReloadHandlerFunc(upgrader websocket.Upgrader) func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + ws, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + c := &connection{send: make(chan []byte, 256), ws: ws} + wsHub.register <- c + defer func() { wsHub.unregister <- c }() + go c.writer() + c.reader() + } +} + +// Initialize starts the Websocket Hub handling live reloads. +func Initialize() { + go wsHub.run() + //go RegularRefresh(10) +} + +func RegularRefresh(t int) { + for { + time.Sleep(time.Duration(t) * time.Second) + Reload("/d/qEYZMimVz/slug") + } +} +func Reload(path string) { + // Tell livereload a file has changed - will force a hard refresh if not CSS or an image + log.Println("\n\nRELOAD.....\n\n") + msg := fmt.Sprintf(`{"command":"reload","path":"%s","originalPath":"","liveCSS":true,"liveImg":true}`, path) + wsHub.broadcast <- []byte(msg) +} + +// This is a patched version, see https://github.com/livereload/livereload-js/pull/84 +// +//go:embed livereload.js +var livereloadJS []byte + +func LiveReloadJSHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Add("Content-Type", "application/javascript") + w.Write(livereloadJS) +} + +func Inject(html []byte, port int) []byte { + fmtstr := `` + inject := fmt.Sprintf(fmtstr, port) + return []byte(strings.ReplaceAll(string(html), "", "\n"+inject)) +} diff --git a/pkg/grizzly/livereload/livereload.js b/pkg/grizzly/livereload/livereload.js new file mode 100644 index 00000000..1f4aa0fb --- /dev/null +++ b/pkg/grizzly/livereload/livereload.js @@ -0,0 +1 @@ +!function(){return function e(t,o,n){function r(s,c){if(!o[s]){if(!t[s]){var a="function"==typeof require&&require;if(!c&&a)return a(s,!0);if(i)return i(s,!0);var l=new Error("Cannot find module '"+s+"'");throw l.code="MODULE_NOT_FOUND",l}var h=o[s]={exports:{}};t[s][0].call(h.exports,function(e){return r(t[s][1][e]||e)},h,h.exports,e,t,o,n)}return o[s].exports}for(var i="function"==typeof require&&require,s=0;sh;)if((c=a[h++])!=c)return!0}else for(;l>h;h++)if((e||h in a)&&a[h]===o)return e||h||0;return!e&&-1}}},{"./_to-absolute-index":38,"./_to-iobject":40,"./_to-length":41}],5:[function(e,t,o){var n={}.toString;t.exports=function(e){return n.call(e).slice(8,-1)}},{}],6:[function(e,t,o){var n=t.exports={version:"2.6.5"};"number"==typeof __e&&(__e=n)},{}],7:[function(e,t,o){var n=e("./_a-function");t.exports=function(e,t,o){if(n(e),void 0===t)return e;switch(o){case 1:return function(o){return e.call(t,o)};case 2:return function(o,n){return e.call(t,o,n)};case 3:return function(o,n,r){return e.call(t,o,n,r)}}return function(){return e.apply(t,arguments)}}},{"./_a-function":1}],8:[function(e,t,o){t.exports=function(e){if(null==e)throw TypeError("Can't call method on "+e);return e}},{}],9:[function(e,t,o){t.exports=!e("./_fails")(function(){return 7!=Object.defineProperty({},"a",{get:function(){return 7}}).a})},{"./_fails":13}],10:[function(e,t,o){var n=e("./_is-object"),r=e("./_global").document,i=n(r)&&n(r.createElement);t.exports=function(e){return i?r.createElement(e):{}}},{"./_global":15,"./_is-object":21}],11:[function(e,t,o){t.exports="constructor,hasOwnProperty,isPrototypeOf,propertyIsEnumerable,toLocaleString,toString,valueOf".split(",")},{}],12:[function(e,t,o){var n=e("./_global"),r=e("./_core"),i=e("./_hide"),s=e("./_redefine"),c=e("./_ctx"),a=function(e,t,o){var l,h,u,d,f=e&a.F,p=e&a.G,_=e&a.S,m=e&a.P,g=e&a.B,y=p?n:_?n[t]||(n[t]={}):(n[t]||{}).prototype,v=p?r:r[t]||(r[t]={}),w=v.prototype||(v.prototype={});for(l in p&&(o=t),o)u=((h=!f&&y&&void 0!==y[l])?y:o)[l],d=g&&h?c(u,n):m&&"function"==typeof u?c(Function.call,u):u,y&&s(y,l,u,e&a.U),v[l]!=u&&i(v,l,d),m&&w[l]!=u&&(w[l]=u)};n.core=r,a.F=1,a.G=2,a.S=4,a.P=8,a.B=16,a.W=32,a.U=64,a.R=128,t.exports=a},{"./_core":6,"./_ctx":7,"./_global":15,"./_hide":17,"./_redefine":34}],13:[function(e,t,o){t.exports=function(e){try{return!!e()}catch(e){return!0}}},{}],14:[function(e,t,o){t.exports=e("./_shared")("native-function-to-string",Function.toString)},{"./_shared":37}],15:[function(e,t,o){var n=t.exports="undefined"!=typeof window&&window.Math==Math?window:"undefined"!=typeof self&&self.Math==Math?self:Function("return this")();"number"==typeof __g&&(__g=n)},{}],16:[function(e,t,o){var n={}.hasOwnProperty;t.exports=function(e,t){return n.call(e,t)}},{}],17:[function(e,t,o){var n=e("./_object-dp"),r=e("./_property-desc");t.exports=e("./_descriptors")?function(e,t,o){return n.f(e,t,r(1,o))}:function(e,t,o){return e[t]=o,e}},{"./_descriptors":9,"./_object-dp":28,"./_property-desc":33}],18:[function(e,t,o){var n=e("./_global").document;t.exports=n&&n.documentElement},{"./_global":15}],19:[function(e,t,o){t.exports=!e("./_descriptors")&&!e("./_fails")(function(){return 7!=Object.defineProperty(e("./_dom-create")("div"),"a",{get:function(){return 7}}).a})},{"./_descriptors":9,"./_dom-create":10,"./_fails":13}],20:[function(e,t,o){var n=e("./_cof");t.exports=Object("z").propertyIsEnumerable(0)?Object:function(e){return"String"==n(e)?e.split(""):Object(e)}},{"./_cof":5}],21:[function(e,t,o){t.exports=function(e){return"object"==typeof e?null!==e:"function"==typeof e}},{}],22:[function(e,t,o){"use strict";var n=e("./_object-create"),r=e("./_property-desc"),i=e("./_set-to-string-tag"),s={};e("./_hide")(s,e("./_wks")("iterator"),function(){return this}),t.exports=function(e,t,o){e.prototype=n(s,{next:r(1,o)}),i(e,t+" Iterator")}},{"./_hide":17,"./_object-create":27,"./_property-desc":33,"./_set-to-string-tag":35,"./_wks":45}],23:[function(e,t,o){"use strict";var n=e("./_library"),r=e("./_export"),i=e("./_redefine"),s=e("./_hide"),c=e("./_iterators"),a=e("./_iter-create"),l=e("./_set-to-string-tag"),h=e("./_object-gpo"),u=e("./_wks")("iterator"),d=!([].keys&&"next"in[].keys()),f=function(){return this};t.exports=function(e,t,o,p,_,m,g){a(o,t,p);var y,v,w,b=function(e){if(!d&&e in L)return L[e];switch(e){case"keys":case"values":return function(){return new o(this,e)}}return function(){return new o(this,e)}},S=t+" Iterator",R="values"==_,k=!1,L=e.prototype,x=L[u]||L["@@iterator"]||_&&L[_],j=x||b(_),C=_?R?b("entries"):j:void 0,O="Array"==t&&L.entries||x;if(O&&(w=h(O.call(new e)))!==Object.prototype&&w.next&&(l(w,S,!0),n||"function"==typeof w[u]||s(w,u,f)),R&&x&&"values"!==x.name&&(k=!0,j=function(){return x.call(this)}),n&&!g||!d&&!k&&L[u]||s(L,u,j),c[t]=j,c[S]=f,_)if(y={values:R?j:b("values"),keys:m?j:b("keys"),entries:C},g)for(v in y)v in L||i(L,v,y[v]);else r(r.P+r.F*(d||k),t,y);return y}},{"./_export":12,"./_hide":17,"./_iter-create":22,"./_iterators":25,"./_library":26,"./_object-gpo":30,"./_redefine":34,"./_set-to-string-tag":35,"./_wks":45}],24:[function(e,t,o){t.exports=function(e,t){return{value:t,done:!!e}}},{}],25:[function(e,t,o){t.exports={}},{}],26:[function(e,t,o){t.exports=!1},{}],27:[function(e,t,o){var n=e("./_an-object"),r=e("./_object-dps"),i=e("./_enum-bug-keys"),s=e("./_shared-key")("IE_PROTO"),c=function(){},a=function(){var t,o=e("./_dom-create")("iframe"),n=i.length;for(o.style.display="none",e("./_html").appendChild(o),o.src="javascript:",(t=o.contentWindow.document).open(),t.write("` - inject := fmt.Sprintf(fmtstr, port) + inject := `` + //inject := fmt.Sprintf(fmtstr, port) return []byte(strings.ReplaceAll(string(html), "", "\n"+inject)) } diff --git a/pkg/grizzly/server.go b/pkg/grizzly/server.go index 0d57ce6e..19d93ba7 100644 --- a/pkg/grizzly/server.go +++ b/pkg/grizzly/server.go @@ -6,9 +6,6 @@ import ( "net/http" "net/http/httputil" "os" - "os/exec" - "path/filepath" - "runtime" "github.com/go-chi/chi" "github.com/go-chi/chi/middleware" @@ -16,7 +13,6 @@ import ( "github.com/grafana/grizzly/pkg/grizzly/livereload" "github.com/hashicorp/go-multierror" log "github.com/sirupsen/logrus" - "gopkg.in/fsnotify.v1" ) type Server struct { @@ -163,42 +159,18 @@ func (p *Server) Start() error { } if p.openBrowser { - path := "/" - - stat, err := os.Stat(p.ResourcePath) + browser, err := NewBrowserInterface(p.Registry, p.ResourcePath, p.port) if err != nil { return err } - - if !stat.IsDir() && p.Resources.Len() == 0 { - return fmt.Errorf("no resources found to proxy") - } - - if !stat.IsDir() && p.Resources.Len() == 1 { - resource := p.Resources.First() - handler, err := p.Registry.GetHandler(resource.Kind()) - if err != nil { - return err - } - proxyHandler, ok := handler.(ProxyHandler) - if !ok { - uid, err := handler.GetUID(resource) - if err != nil { - return err - } - return fmt.Errorf("kind %s (for resource %s) does not support proxying", resource.Kind(), uid) - } - proxyURL, err := proxyHandler.ProxyURL(resource) - if err != nil { - return err - } - path = proxyURL - } - - p.openInBrowser(p.URL(path)) + browser.Open(p.Resources) } if p.Watch { - err := p.setupWatch() + watcher, err := NewWatcher(p.updateWatchedResource) + if err != nil { + return err + } + err = watcher.Watch(p.ResourcePath) if err != nil { return err } @@ -223,69 +195,6 @@ func (p *Server) URL(path string) string { return fmt.Sprintf("http://localhost:%d%s", p.port, path) } -func (p *Server) openInBrowser(url string) { - var err error - - switch runtime.GOOS { - case "linux": - err = exec.Command("xdg-open", url).Start() - case "windows": - err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() - case "darwin": - err = exec.Command("open", url).Start() - default: - err = fmt.Errorf("unsupported platform") - } - if err != nil { - log.Fatal(err) - } -} - -func (p *Server) setupWatch() error { - watcher, err := fsnotify.NewWatcher() - if err != nil { - return err - } - //defer watcher.Close() - - err = filepath.WalkDir(p.ResourcePath, func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - if d.IsDir() { - return watcher.Add(path) - } - return nil - }) - if err != nil { - return err - } - - go func() { - log.Info("Watching for changes") - for { - select { - case event, ok := <-watcher.Events: - if !ok { - return - } - if event.Op&fsnotify.Write == fsnotify.Write { - log.Info("Changes detected. Parsing") - err := p.updateWatchedResource(event.Name) - if err != nil { - log.Error("error: ", err) - } - } - case err, ok := <-watcher.Errors: - if !ok { - return - } - log.Error("error: ", err) - } - } - }() - return nil -} func (p *Server) updateWatchedResource(name string) error { log.Info("Changes detected. Applying ", name) resources, err := p.ParseResources(name) diff --git a/pkg/grizzly/watch.go b/pkg/grizzly/watch.go new file mode 100644 index 00000000..9d5a7e14 --- /dev/null +++ b/pkg/grizzly/watch.go @@ -0,0 +1,67 @@ +package grizzly + +import ( + "io/fs" + "path/filepath" + + log "github.com/sirupsen/logrus" + "gopkg.in/fsnotify.v1" +) + +type Watcher struct { + watcher *fsnotify.Watcher + watcherFunc func(string) error +} + +func NewWatcher(watcherFunc func(path string) error) (*Watcher, error) { + w, err := fsnotify.NewWatcher() + if err != nil { + return nil, err + } + watcher := Watcher{ + watcher: w, + watcherFunc: watcherFunc, + } + return &watcher, nil + +} + +func (w *Watcher) Watch(path string) error { + err := filepath.WalkDir(path, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return w.watcher.Add(path) + } + return nil + }) + if err != nil { + return err + } + + go func() { + log.Info("Watching for changes") + for { + select { + case event, ok := <-w.watcher.Events: + if !ok { + return + } + if event.Op&fsnotify.Write == fsnotify.Write { + log.Info("Changes detected. Parsing") + err := w.watcherFunc(event.Name) + if err != nil { + log.Error("error: ", err) + } + } + case err, ok := <-w.watcher.Errors: + if !ok { + return + } + log.Error("error: ", err) + } + } + }() + return nil +} From 51ae143019df5c511f64ff4c50b4594e05b97c4b Mon Sep 17 00:00:00 2001 From: Malcolm Holmes Date: Tue, 30 Apr 2024 10:19:09 +0100 Subject: [PATCH 05/11] Use Grafana's 'live' functionality for reloads --- go.mod | 2 +- pkg/grizzly/livereload/connection.go | 217 +++++++++++++++++++++++---- pkg/grizzly/livereload/hub.go | 24 ++- pkg/grizzly/livereload/livereload.go | 28 +--- pkg/grizzly/livereload/livereload.js | 1 - pkg/grizzly/server.go | 18 +-- 6 files changed, 212 insertions(+), 78 deletions(-) delete mode 100644 pkg/grizzly/livereload/livereload.js diff --git a/go.mod b/go.mod index 55449cc0..b1cad7df 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/go-openapi/runtime v0.28.0 github.com/gobwas/glob v0.2.3 github.com/google/go-jsonnet v0.20.0 + github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.1 github.com/grafana/grafana-openapi-client-go v0.0.0-20240325012504-4958bdd139e7 github.com/grafana/synthetic-monitoring-agent v0.23.1 @@ -50,7 +51,6 @@ require ( github.com/go-openapi/validate v0.24.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/protobuf v1.5.3 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/josharian/intern v1.0.0 // indirect diff --git a/pkg/grizzly/livereload/connection.go b/pkg/grizzly/livereload/connection.go index 9a987e37..4f9c5443 100644 --- a/pkg/grizzly/livereload/connection.go +++ b/pkg/grizzly/livereload/connection.go @@ -1,35 +1,21 @@ -// Copyright 2015 The Hugo Authors. All rights reserved. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - package livereload import ( - "bytes" + "encoding/json" + "fmt" + "log" + "strings" "sync" + "github.com/google/uuid" "github.com/gorilla/websocket" ) type connection struct { - // The websocket connection. - ws *websocket.Conn - - // Buffered channel of outbound messages. - send chan []byte - - // There is a potential data race, especially visible with large files. - // This is protected by synchronization of the send channel's close. - closer sync.Once + ws *websocket.Conn + send chan []byte + closer sync.Once + clientID string } func NewConnection(send chan []byte, ws *websocket.Conn) *connection { @@ -45,23 +31,196 @@ func (c *connection) close() { }) } +type connectRequest struct { + ID int `json:"id"` +} + +type connectResponseInner struct { + Client string `json:"client"` + Ping int `json:"ping"` + Pong bool `json:"pong"` +} +type connectResponse struct { + ID int `json:"id"` + Connect connectResponseInner `json:"connect"` +} + +func (c *connection) handleConnectRequest(line string) ([]byte, error) { + // {"connect":{"name":"js"},"id":1} + request := connectRequest{} + err := json.Unmarshal([]byte(line), &request) + if err != nil { + return nil, err + } + c.clientID = uuid.New().String() + // {"id":1,"connect":{"client":"5a6674c9-2450-46e4-bfff-beaa84966493","ping":25,"pong":true}} + response := connectResponse{ + ID: request.ID, + Connect: connectResponseInner{ + Client: c.clientID, + Ping: 25, + Pong: true, + }, + } + j, err := json.Marshal(response) + if err != nil { + return nil, err + } + return j, nil +} + +type subscribeRequestInner struct { + Channel string `json:"channel"` +} +type subscribeRequest struct { + ID int `json:"id"` + Subscribe subscribeRequestInner `json:"subscribe"` +} + +type joinResponseInfo struct { + User string `json:"user"` + Client string `json:"client"` +} +type joinResponseJoin struct { + Info joinResponseInfo `json:"info"` +} +type joinResponsePush struct { + Channel string `json:"channel"` + Join joinResponseJoin `json:"join"` +} +type joinResponse struct { + Push joinResponsePush `json:"push"` +} + +func (c *connection) handleSubscribeRequest(line string) ([]byte, error) { + // {"subscribe":{"channel":"1/grafana/dashboard/uid/no-folder"},"id":2} + request := subscribeRequest{} + err := json.Unmarshal([]byte(line), &request) + if err != nil { + return nil, err + } + // {"id":2,"subscribe":{}} + subResp := map[string]any{} + subResp["id"] = request.ID + subResp["subscribe"] = map[string]any{} + j, err := json.Marshal(subResp) + if err != nil { + return nil, err + } + c.send <- j + // {"push":{"channel":"1/grafana/dashboard/uid/no-folder","join":{"info":{"user":"1","client":"5a6674c9-2450-46e4-bfff-beaa84966493"}}}} + joinResp := joinResponse{ + Push: joinResponsePush{ + Channel: request.Subscribe.Channel, + Join: joinResponseJoin{ + Info: joinResponseInfo{ + User: "1", + Client: c.clientID, + }, + }, + }, + } + j, err = json.Marshal(joinResp) + if err != nil { + return nil, err + } + return j, nil +} + func (c *connection) reader() { for { _, message, err := c.ws.ReadMessage() if err != nil { break } - if bytes.Contains(message, []byte(`"command":"hello"`)) { - c.send <- []byte(`{ - "command": "hello", - "protocols": [ "http://livereload.com/protocols/official-7" ], - "serverName": "Hugo" - }`) + lines := strings.Split(string(message), "\n") + for _, line := range lines { + msg := map[string]any{} + err := json.Unmarshal([]byte(line), &msg) + if err != nil { + log.Printf("Error parsing websocket message: %v", err) + continue + } + if _, ok := msg["connect"]; ok { + j, err := c.handleConnectRequest(line) + if err != nil { + log.Printf("Error handling connection request: %s", err) + continue + } + c.send <- j + } else if _, ok := msg["subscribe"]; ok { + j, err := c.handleSubscribeRequest(line) + if err != nil { + log.Printf("Error handling subscribe request: %s", err) + continue + } + c.send <- j + } } } c.ws.Close() } +type pushResponseUser struct { + ID int `json:"id"` + Login string `json:"login"` +} +type pushResponseDashboard struct { + Uid string `json:"uid"` + FolderID int `json:"folderID"` + IsFolder bool `json:"IsFolder"` + Data map[string]any `json:"data"` +} + +type pushResponseData struct { + Uid string `json:"uid"` + Action string `json:"action"` + User pushResponseUser `json:"user"` + Dashboard pushResponseDashboard `json:"dashboard"` +} +type pushResponsePub struct { + Data pushResponseData `json:"data"` +} + +type pushResponsePush struct { + Channel string `json:"channel"` + Pub pushResponsePub `json:"pub"` +} + +type pushResponse struct { + Push pushResponsePush `json:"push"` +} + +func (c *connection) NotifyDashboard(uid string, spec map[string]any) error { + response := pushResponse{ + Push: pushResponsePush{ + Channel: fmt.Sprintf("1/grafana/dashboard/uid/%s", uid), + Pub: pushResponsePub{ + Data: pushResponseData{ + Uid: uid, + Action: "saved", + User: pushResponseUser{ + ID: 1, + Login: "admin", + }, + Dashboard: pushResponseDashboard{ + Uid: uid, + FolderID: 0, + IsFolder: false, + Data: spec, + }, + }, + }, + }, + } + j, err := json.Marshal(response) + if err != nil { + return err + } + c.send <- j + return nil +} + func (c *connection) writer() { for message := range c.send { err := c.ws.WriteMessage(websocket.TextMessage, message) diff --git a/pkg/grizzly/livereload/hub.go b/pkg/grizzly/livereload/hub.go index 6b337414..57e3b2c8 100644 --- a/pkg/grizzly/livereload/hub.go +++ b/pkg/grizzly/livereload/hub.go @@ -13,13 +13,12 @@ package livereload +import "log" + type hub struct { // Registered connections. connections map[*connection]bool - // Inbound messages from the connections. - broadcast chan []byte - // Register requests from the connections. register chan *connection @@ -28,7 +27,6 @@ type hub struct { } var wsHub = hub{ - broadcast: make(chan []byte), register: make(chan *connection), unregister: make(chan *connection), connections: make(map[*connection]bool), @@ -42,6 +40,15 @@ func Unregister(c *connection) { wsHub.unregister <- c } +func (h *hub) NotifyDashboard(uid string, spec map[string]any) { + for c := range h.connections { + err := c.NotifyDashboard(uid, spec) + if err != nil { + log.Printf("Error notifying %s: %s", c.clientID, err) + } + } +} + func (h *hub) run() { for { select { @@ -50,15 +57,6 @@ func (h *hub) run() { case c := <-h.unregister: delete(h.connections, c) c.close() - case m := <-h.broadcast: - for c := range h.connections { - select { - case c.send <- m: - default: - delete(h.connections, c) - c.close() - } - } } } } diff --git a/pkg/grizzly/livereload/livereload.go b/pkg/grizzly/livereload/livereload.go index d98f3988..bbe284a2 100644 --- a/pkg/grizzly/livereload/livereload.go +++ b/pkg/grizzly/livereload/livereload.go @@ -5,7 +5,6 @@ import ( "fmt" "log" "net/http" - "strings" "github.com/gorilla/websocket" ) @@ -31,24 +30,11 @@ func Initialize() { go wsHub.run() } -func Reload(path string) { - log.Printf("Reloading %s", path) - msg := fmt.Sprintf(`{"command":"reload","path":"%s","originalPath":"%s"}`, path, path) - wsHub.broadcast <- []byte(msg) -} - -// This is a patched version, see https://github.com/livereload/livereload-js/pull/84, cloned from github.com/gohugoio/hugo -// -//go:embed livereload.js -var livereloadJS []byte - -func LiveReloadJSHandler(w http.ResponseWriter, r *http.Request) { - w.Header().Add("Content-Type", "application/javascript") - w.Write(livereloadJS) -} - -func Inject(html []byte, port int) []byte { - inject := `` - //inject := fmt.Sprintf(fmtstr, port) - return []byte(strings.ReplaceAll(string(html), "", "\n"+inject)) +func Reload(kind, name string, spec map[string]any) error { + log.Printf("Reloading %s/%s", kind, name) + if kind != "Dashboard" { + return fmt.Errorf("only dashboards supported for live reload at present") + } + wsHub.NotifyDashboard(name, spec) + return nil } diff --git a/pkg/grizzly/livereload/livereload.js b/pkg/grizzly/livereload/livereload.js deleted file mode 100644 index 1f4aa0fb..00000000 --- a/pkg/grizzly/livereload/livereload.js +++ /dev/null @@ -1 +0,0 @@ -!function(){return function e(t,o,n){function r(s,c){if(!o[s]){if(!t[s]){var a="function"==typeof require&&require;if(!c&&a)return a(s,!0);if(i)return i(s,!0);var l=new Error("Cannot find module '"+s+"'");throw l.code="MODULE_NOT_FOUND",l}var h=o[s]={exports:{}};t[s][0].call(h.exports,function(e){return r(t[s][1][e]||e)},h,h.exports,e,t,o,n)}return o[s].exports}for(var i="function"==typeof require&&require,s=0;sh;)if((c=a[h++])!=c)return!0}else for(;l>h;h++)if((e||h in a)&&a[h]===o)return e||h||0;return!e&&-1}}},{"./_to-absolute-index":38,"./_to-iobject":40,"./_to-length":41}],5:[function(e,t,o){var n={}.toString;t.exports=function(e){return n.call(e).slice(8,-1)}},{}],6:[function(e,t,o){var n=t.exports={version:"2.6.5"};"number"==typeof __e&&(__e=n)},{}],7:[function(e,t,o){var n=e("./_a-function");t.exports=function(e,t,o){if(n(e),void 0===t)return e;switch(o){case 1:return function(o){return e.call(t,o)};case 2:return function(o,n){return e.call(t,o,n)};case 3:return function(o,n,r){return e.call(t,o,n,r)}}return function(){return e.apply(t,arguments)}}},{"./_a-function":1}],8:[function(e,t,o){t.exports=function(e){if(null==e)throw TypeError("Can't call method on "+e);return e}},{}],9:[function(e,t,o){t.exports=!e("./_fails")(function(){return 7!=Object.defineProperty({},"a",{get:function(){return 7}}).a})},{"./_fails":13}],10:[function(e,t,o){var n=e("./_is-object"),r=e("./_global").document,i=n(r)&&n(r.createElement);t.exports=function(e){return i?r.createElement(e):{}}},{"./_global":15,"./_is-object":21}],11:[function(e,t,o){t.exports="constructor,hasOwnProperty,isPrototypeOf,propertyIsEnumerable,toLocaleString,toString,valueOf".split(",")},{}],12:[function(e,t,o){var n=e("./_global"),r=e("./_core"),i=e("./_hide"),s=e("./_redefine"),c=e("./_ctx"),a=function(e,t,o){var l,h,u,d,f=e&a.F,p=e&a.G,_=e&a.S,m=e&a.P,g=e&a.B,y=p?n:_?n[t]||(n[t]={}):(n[t]||{}).prototype,v=p?r:r[t]||(r[t]={}),w=v.prototype||(v.prototype={});for(l in p&&(o=t),o)u=((h=!f&&y&&void 0!==y[l])?y:o)[l],d=g&&h?c(u,n):m&&"function"==typeof u?c(Function.call,u):u,y&&s(y,l,u,e&a.U),v[l]!=u&&i(v,l,d),m&&w[l]!=u&&(w[l]=u)};n.core=r,a.F=1,a.G=2,a.S=4,a.P=8,a.B=16,a.W=32,a.U=64,a.R=128,t.exports=a},{"./_core":6,"./_ctx":7,"./_global":15,"./_hide":17,"./_redefine":34}],13:[function(e,t,o){t.exports=function(e){try{return!!e()}catch(e){return!0}}},{}],14:[function(e,t,o){t.exports=e("./_shared")("native-function-to-string",Function.toString)},{"./_shared":37}],15:[function(e,t,o){var n=t.exports="undefined"!=typeof window&&window.Math==Math?window:"undefined"!=typeof self&&self.Math==Math?self:Function("return this")();"number"==typeof __g&&(__g=n)},{}],16:[function(e,t,o){var n={}.hasOwnProperty;t.exports=function(e,t){return n.call(e,t)}},{}],17:[function(e,t,o){var n=e("./_object-dp"),r=e("./_property-desc");t.exports=e("./_descriptors")?function(e,t,o){return n.f(e,t,r(1,o))}:function(e,t,o){return e[t]=o,e}},{"./_descriptors":9,"./_object-dp":28,"./_property-desc":33}],18:[function(e,t,o){var n=e("./_global").document;t.exports=n&&n.documentElement},{"./_global":15}],19:[function(e,t,o){t.exports=!e("./_descriptors")&&!e("./_fails")(function(){return 7!=Object.defineProperty(e("./_dom-create")("div"),"a",{get:function(){return 7}}).a})},{"./_descriptors":9,"./_dom-create":10,"./_fails":13}],20:[function(e,t,o){var n=e("./_cof");t.exports=Object("z").propertyIsEnumerable(0)?Object:function(e){return"String"==n(e)?e.split(""):Object(e)}},{"./_cof":5}],21:[function(e,t,o){t.exports=function(e){return"object"==typeof e?null!==e:"function"==typeof e}},{}],22:[function(e,t,o){"use strict";var n=e("./_object-create"),r=e("./_property-desc"),i=e("./_set-to-string-tag"),s={};e("./_hide")(s,e("./_wks")("iterator"),function(){return this}),t.exports=function(e,t,o){e.prototype=n(s,{next:r(1,o)}),i(e,t+" Iterator")}},{"./_hide":17,"./_object-create":27,"./_property-desc":33,"./_set-to-string-tag":35,"./_wks":45}],23:[function(e,t,o){"use strict";var n=e("./_library"),r=e("./_export"),i=e("./_redefine"),s=e("./_hide"),c=e("./_iterators"),a=e("./_iter-create"),l=e("./_set-to-string-tag"),h=e("./_object-gpo"),u=e("./_wks")("iterator"),d=!([].keys&&"next"in[].keys()),f=function(){return this};t.exports=function(e,t,o,p,_,m,g){a(o,t,p);var y,v,w,b=function(e){if(!d&&e in L)return L[e];switch(e){case"keys":case"values":return function(){return new o(this,e)}}return function(){return new o(this,e)}},S=t+" Iterator",R="values"==_,k=!1,L=e.prototype,x=L[u]||L["@@iterator"]||_&&L[_],j=x||b(_),C=_?R?b("entries"):j:void 0,O="Array"==t&&L.entries||x;if(O&&(w=h(O.call(new e)))!==Object.prototype&&w.next&&(l(w,S,!0),n||"function"==typeof w[u]||s(w,u,f)),R&&x&&"values"!==x.name&&(k=!0,j=function(){return x.call(this)}),n&&!g||!d&&!k&&L[u]||s(L,u,j),c[t]=j,c[S]=f,_)if(y={values:R?j:b("values"),keys:m?j:b("keys"),entries:C},g)for(v in y)v in L||i(L,v,y[v]);else r(r.P+r.F*(d||k),t,y);return y}},{"./_export":12,"./_hide":17,"./_iter-create":22,"./_iterators":25,"./_library":26,"./_object-gpo":30,"./_redefine":34,"./_set-to-string-tag":35,"./_wks":45}],24:[function(e,t,o){t.exports=function(e,t){return{value:t,done:!!e}}},{}],25:[function(e,t,o){t.exports={}},{}],26:[function(e,t,o){t.exports=!1},{}],27:[function(e,t,o){var n=e("./_an-object"),r=e("./_object-dps"),i=e("./_enum-bug-keys"),s=e("./_shared-key")("IE_PROTO"),c=function(){},a=function(){var t,o=e("./_dom-create")("iframe"),n=i.length;for(o.style.display="none",e("./_html").appendChild(o),o.src="javascript:",(t=o.contentWindow.document).open(),t.write("