From e92462765979419bf19f506d57a6721d8771b516 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Thu, 4 Jan 2024 08:00:07 +0100 Subject: [PATCH] Frontend: Reload the browser when backend configuration/assets change (#79057) * Detect frontend asset changes * Update * merge main * Frontend: Detect new assets / versions / config changes (#79258) * avoid first check * Updates and add tests * Update * Update * Updated code * refine * use context --------- Co-authored-by: Ryan McKinley --- packages/grafana-data/src/types/config.ts | 2 +- pkg/api/api.go | 2 + pkg/api/dtos/index.go | 26 ++-- pkg/api/frontendsettings.go | 59 +++++++++ pkg/api/http_server.go | 2 +- pkg/api/index.go | 7 +- pkg/api/login_test.go | 6 +- pkg/api/webassets/webassets.go | 64 ++++++++-- pkg/api/webassets/webassets_test.go | 114 +++++++++++++++--- pkg/middleware/middleware_test.go | 6 +- pkg/middleware/recovery.go | 7 +- pkg/middleware/recovery_test.go | 3 +- pkg/services/contexthandler/model/model.go | 2 +- public/app/app.ts | 4 + public/app/core/context/GrafanaContext.ts | 2 + .../services/NewFrontendAssetsChecker.test.ts | 49 ++++++++ .../core/services/NewFrontendAssetsChecker.ts | 112 +++++++++++++++++ public/app/core/services/theme.ts | 2 +- public/test/mocks/getGrafanaContextMock.ts | 5 + public/views/error.html | 10 +- public/views/index.html | 17 ++- 21 files changed, 433 insertions(+), 68 deletions(-) create mode 100644 public/app/core/services/NewFrontendAssetsChecker.test.ts create mode 100644 public/app/core/services/NewFrontendAssetsChecker.ts diff --git a/packages/grafana-data/src/types/config.ts b/packages/grafana-data/src/types/config.ts index 90a1ebbb3d2..3c27a4218c6 100644 --- a/packages/grafana-data/src/types/config.ts +++ b/packages/grafana-data/src/types/config.ts @@ -135,7 +135,7 @@ export interface BootData { user: CurrentUserDTO; settings: GrafanaConfig; navTree: NavLinkDTO[]; - themePaths: { + assets: { light: string; dark: string; }; diff --git a/pkg/api/api.go b/pkg/api/api.go index e3b93836cac..7e01aca3c73 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -416,6 +416,8 @@ func (hs *HTTPServer) registerRoutes() { } apiRoute.Get("/frontend/settings/", hs.GetFrontendSettings) + apiRoute.Get("/frontend/assets", hs.GetFrontendAssets) + apiRoute.Any("/datasources/proxy/:id/*", requestmeta.SetSLOGroup(requestmeta.SLOGroupHighSlow), authorize(ac.EvalPermission(datasources.ActionQuery)), hs.ProxyDataSourceRequest) apiRoute.Any("/datasources/proxy/uid/:uid/*", requestmeta.SetSLOGroup(requestmeta.SLOGroupHighSlow), authorize(ac.EvalPermission(datasources.ActionQuery)), hs.ProxyDataSourceRequestWithUID) apiRoute.Any("/datasources/proxy/:id", requestmeta.SetSLOGroup(requestmeta.SLOGroupHighSlow), authorize(ac.EvalPermission(datasources.ActionQuery)), hs.ProxyDataSourceRequest) diff --git a/pkg/api/dtos/index.go b/pkg/api/dtos/index.go index ff3aa17d5ad..89d5bff6882 100644 --- a/pkg/api/dtos/index.go +++ b/pkg/api/dtos/index.go @@ -26,7 +26,6 @@ type IndexViewData struct { FavIcon template.URL AppleTouchIcon template.URL AppTitle string - ContentDeliveryURL string LoadingLogo template.URL CSPContent string CSPEnabled bool @@ -34,16 +33,29 @@ type IndexViewData struct { // Nonce is a cryptographic identifier for use with Content Security Policy. Nonce string NewsFeedEnabled bool - Assets *EntryPointAssets + Assets *EntryPointAssets // Includes CDN info } type EntryPointAssets struct { - JSFiles []EntryPointAsset - CSSDark string - CSSLight string + ContentDeliveryURL string `json:"cdn,omitempty"` + JSFiles []EntryPointAsset `json:"jsFiles"` + Dark string `json:"dark"` + Light string `json:"light"` } type EntryPointAsset struct { - FilePath string - Integrity string + FilePath string `json:"filePath"` + Integrity string `json:"integrity"` +} + +func (a *EntryPointAssets) SetContentDeliveryURL(prefix string) { + if prefix == "" { + return + } + a.ContentDeliveryURL = prefix + a.Dark = prefix + a.Dark + a.Light = prefix + a.Light + for i, p := range a.JSFiles { + a.JSFiles[i].FilePath = prefix + p.FilePath + } } diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go index 262185ec2e4..e4dc720273b 100644 --- a/pkg/api/frontendsettings.go +++ b/pkg/api/frontendsettings.go @@ -2,12 +2,16 @@ package api import ( "context" + "crypto/sha256" "fmt" + "hash" "net/http" "slices" + "sort" "strings" "github.com/grafana/grafana/pkg/api/dtos" + "github.com/grafana/grafana/pkg/api/webassets" "github.com/grafana/grafana/pkg/login/social" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/services/accesscontrol" @@ -24,6 +28,61 @@ import ( "github.com/grafana/grafana/pkg/util" ) +// Returns a file that is easy to check for changes +// Any changes to the file means we should refresh the frontend +func (hs *HTTPServer) GetFrontendAssets(c *contextmodel.ReqContext) { + hash := sha256.New() + keys := map[string]any{} + + // BuildVersion + hash.Reset() + _, _ = hash.Write([]byte(setting.BuildVersion)) + _, _ = hash.Write([]byte(setting.BuildCommit)) + _, _ = hash.Write([]byte(fmt.Sprintf("%d", setting.BuildStamp))) + keys["version"] = fmt.Sprintf("%x", hash.Sum(nil)) + + // Plugin configs + plugins := []string{} + for _, p := range hs.pluginStore.Plugins(c.Req.Context()) { + plugins = append(plugins, fmt.Sprintf("%s@%s", p.Name, p.Info.Version)) + } + keys["plugins"] = sortedHash(plugins, hash) + + // Feature flags + enabled := []string{} + for flag, set := range hs.Features.GetEnabled(c.Req.Context()) { + if set { + enabled = append(enabled, flag) + } + } + keys["flags"] = sortedHash(enabled, hash) + + // Assets + hash.Reset() + dto, err := webassets.GetWebAssets(c.Req.Context(), hs.Cfg, hs.License) + if err == nil && dto != nil { + _, _ = hash.Write([]byte(dto.ContentDeliveryURL)) + _, _ = hash.Write([]byte(dto.Dark)) + _, _ = hash.Write([]byte(dto.Light)) + for _, f := range dto.JSFiles { + _, _ = hash.Write([]byte(f.FilePath)) + _, _ = hash.Write([]byte(f.Integrity)) + } + } + keys["assets"] = fmt.Sprintf("%x", hash.Sum(nil)) + + c.JSON(http.StatusOK, keys) +} + +func sortedHash(vals []string, hash hash.Hash) string { + hash.Reset() + sort.Strings(vals) + for _, v := range vals { + _, _ = hash.Write([]byte(v)) + } + return fmt.Sprintf("%x", hash.Sum(nil)) +} + func (hs *HTTPServer) GetFrontendSettings(c *contextmodel.ReqContext) { settings, err := hs.getFrontendSettings(c) if err != nil { diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go index cb1c37cc3ad..f7dc7647bce 100644 --- a/pkg/api/http_server.go +++ b/pkg/api/http_server.go @@ -656,7 +656,7 @@ func (hs *HTTPServer) addMiddlewaresAndStaticRoutes() { m.UseMiddleware(middleware.Gziper()) } - m.UseMiddleware(middleware.Recovery(hs.Cfg)) + m.UseMiddleware(middleware.Recovery(hs.Cfg, hs.License)) m.UseMiddleware(hs.Csrf.Middleware()) hs.mapStatic(m, hs.Cfg.StaticRootPath, "build", "public/build") diff --git a/pkg/api/index.go b/pkg/api/index.go index 7656a1e91e1..50fcf31c5b9 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -81,7 +81,7 @@ func (hs *HTTPServer) setIndexViewData(c *contextmodel.ReqContext) (*dtos.IndexV } theme := hs.getThemeForIndexData(prefs.Theme, c.Query("theme")) - assets, err := webassets.GetWebAssets(hs.Cfg) + assets, err := webassets.GetWebAssets(c.Req.Context(), hs.Cfg, hs.License) if err != nil { return nil, err } @@ -98,10 +98,6 @@ func (hs *HTTPServer) setIndexViewData(c *contextmodel.ReqContext) (*dtos.IndexV hasAccess := ac.HasAccess(hs.AccessControl, c) hasEditPerm := hasAccess(ac.EvalAny(ac.EvalPermission(dashboards.ActionDashboardsCreate), ac.EvalPermission(dashboards.ActionFoldersCreate))) - cdnURL, err := hs.Cfg.GetContentDeliveryURL(hs.License.ContentDeliveryPrefix()) - if err != nil { - return nil, err - } data := dtos.IndexViewData{ User: &dtos.CurrentUser{ @@ -147,7 +143,6 @@ func (hs *HTTPServer) setIndexViewData(c *contextmodel.ReqContext) (*dtos.IndexV AppTitle: "Grafana", NavTree: navTree, Nonce: c.RequestNonce, - ContentDeliveryURL: cdnURL, LoadingLogo: "public/img/grafana_icon.svg", IsDevelopmentEnv: hs.Cfg.Env == setting.Dev, Assets: assets, diff --git a/pkg/api/login_test.go b/pkg/api/login_test.go index 5eecd25c1e4..9dadd5470d5 100644 --- a/pkg/api/login_test.go +++ b/pkg/api/login_test.go @@ -53,9 +53,9 @@ func fakeSetIndexViewData(t *testing.T) { Settings: &dtos.FrontendSettingsDTO{}, NavTree: &navtree.NavTreeRoot{}, Assets: &dtos.EntryPointAssets{ - JSFiles: []dtos.EntryPointAsset{}, - CSSDark: "dark.css", - CSSLight: "light.css", + JSFiles: []dtos.EntryPointAsset{}, + Dark: "dark.css", + Light: "light.css", }, } return data, nil diff --git a/pkg/api/webassets/webassets.go b/pkg/api/webassets/webassets.go index c1f4aa108a7..197c5f76281 100644 --- a/pkg/api/webassets/webassets.go +++ b/pkg/api/webassets/webassets.go @@ -1,12 +1,16 @@ package webassets import ( + "context" "encoding/json" "fmt" + "io" + "net/http" "os" "path/filepath" "github.com/grafana/grafana/pkg/api/dtos" + "github.com/grafana/grafana/pkg/services/licensing" "github.com/grafana/grafana/pkg/setting" ) @@ -29,27 +33,67 @@ type EntryPointInfo struct { var entryPointAssetsCache *dtos.EntryPointAssets = nil -func GetWebAssets(cfg *setting.Cfg) (*dtos.EntryPointAssets, error) { +func GetWebAssets(ctx context.Context, cfg *setting.Cfg, license licensing.Licensing) (*dtos.EntryPointAssets, error) { if cfg.Env != setting.Dev && entryPointAssetsCache != nil { return entryPointAssetsCache, nil } - result, err := readWebAssets(filepath.Join(cfg.StaticRootPath, "build", "assets-manifest.json")) - entryPointAssetsCache = result + var err error + var result *dtos.EntryPointAssets + + cdn := "" // "https://grafana-assets.grafana.net/grafana/10.3.0-64123/" + if cdn != "" { + result, err = readWebAssetsFromCDN(ctx, cdn) + } + + if result == nil { + result, err = readWebAssetsFromFile(filepath.Join(cfg.StaticRootPath, "build", "assets-manifest.json")) + if err == nil { + cdn, _ = cfg.GetContentDeliveryURL(license.ContentDeliveryPrefix()) + if cdn != "" { + result.SetContentDeliveryURL(cdn) + } + } + } + entryPointAssetsCache = result return entryPointAssetsCache, err } -func readWebAssets(manifestpath string) (*dtos.EntryPointAssets, error) { +func readWebAssetsFromFile(manifestpath string) (*dtos.EntryPointAssets, error) { //nolint:gosec - bytes, err := os.ReadFile(manifestpath) + f, err := os.Open(manifestpath) if err != nil { return nil, fmt.Errorf("failed to load assets-manifest.json %w", err) } + defer func() { + _ = f.Close() + }() + return readWebAssets(f) +} + +func readWebAssetsFromCDN(ctx context.Context, baseURL string) (*dtos.EntryPointAssets, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"public/build/assets-manifest.json", nil) + if err != nil { + return nil, err + } + response, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer func() { + _ = response.Body.Close() + }() + dto, err := readWebAssets(response.Body) + if err == nil { + dto.SetContentDeliveryURL(baseURL) + } + return dto, err +} +func readWebAssets(r io.Reader) (*dtos.EntryPointAssets, error) { manifest := map[string]ManifestInfo{} - err = json.Unmarshal(bytes, &manifest) - if err != nil { + if err := json.NewDecoder(r).Decode(&manifest); err != nil { return nil, fmt.Errorf("failed to read assets-manifest.json %w", err) } @@ -84,8 +128,8 @@ func readWebAssets(manifestpath string) (*dtos.EntryPointAssets, error) { } return &dtos.EntryPointAssets{ - JSFiles: entryPointJSAssets, - CSSDark: entryPoints.Dark.Assets.CSS[0], - CSSLight: entryPoints.Light.Assets.CSS[0], + JSFiles: entryPointJSAssets, + Dark: entryPoints.Dark.Assets.CSS[0], + Light: entryPoints.Light.Assets.CSS[0], }, nil } diff --git a/pkg/api/webassets/webassets_test.go b/pkg/api/webassets/webassets_test.go index f12f488cfef..4c4f0013cac 100644 --- a/pkg/api/webassets/webassets_test.go +++ b/pkg/api/webassets/webassets_test.go @@ -1,6 +1,7 @@ package webassets import ( + "context" "encoding/json" "testing" @@ -8,7 +9,7 @@ import ( ) func TestReadWebassets(t *testing.T) { - assets, err := readWebAssets("testdata/sample-assets-manifest.json") + assets, err := readWebAssetsFromFile("testdata/sample-assets-manifest.json") require.NoError(t, err) dto, err := json.MarshalIndent(assets, "", " ") @@ -16,33 +17,114 @@ func TestReadWebassets(t *testing.T) { //fmt.Printf("%s\n", string(dto)) require.JSONEq(t, `{ - "JSFiles": [ + "jsFiles": [ { - "FilePath": "public/build/runtime.20ed8c01880b812ed29f.js", - "Integrity": "sha256-rcdxIHk6cWgu4jiFa1a+pWlileYD/R72GaS8ZACBUdw= sha384-I/VJZQkt+TuJTvu61ihdWPds7EHfLrW5CxeQ0x9gtSqoPg9Z17Uawz1yoYaTdxqQ sha512-4CPAbh4KdTmGxHoQw4pgpYmgAquupVfwfo6UBV2cGU3vGFnEwkhq320037ETwWs+n9xB/bAMOvrdabp1SA1+8g==" + "filePath": "public/build/runtime.20ed8c01880b812ed29f.js", + "integrity": "sha256-rcdxIHk6cWgu4jiFa1a+pWlileYD/R72GaS8ZACBUdw= sha384-I/VJZQkt+TuJTvu61ihdWPds7EHfLrW5CxeQ0x9gtSqoPg9Z17Uawz1yoYaTdxqQ sha512-4CPAbh4KdTmGxHoQw4pgpYmgAquupVfwfo6UBV2cGU3vGFnEwkhq320037ETwWs+n9xB/bAMOvrdabp1SA1+8g==" }, { - "FilePath": "public/build/3951.4e474348841d792ab1ba.js", - "Integrity": "sha256-dHqXXTRA3osYhHr9rol8hOV0nC4VP0pr5tbMp5VD95Q= sha384-4QJaSTibnxdYeYsLnmXtd1+If6IkAmXlLR0uYHN5+N+fS0FegHRH7MIFaRGjiO1B sha512-vRLEeEGbxBCx0z+l/m14fSK49reqWGA9zQzsCrD+TQQBmP07YIoRPwopMMyxtKljbbRFV0bW2bUZ7ZvzOZYoIQ==" + "filePath": "public/build/3951.4e474348841d792ab1ba.js", + "integrity": "sha256-dHqXXTRA3osYhHr9rol8hOV0nC4VP0pr5tbMp5VD95Q= sha384-4QJaSTibnxdYeYsLnmXtd1+If6IkAmXlLR0uYHN5+N+fS0FegHRH7MIFaRGjiO1B sha512-vRLEeEGbxBCx0z+l/m14fSK49reqWGA9zQzsCrD+TQQBmP07YIoRPwopMMyxtKljbbRFV0bW2bUZ7ZvzOZYoIQ==" }, { - "FilePath": "public/build/3651.4e8f7603e9778e1e9b59.js", - "Integrity": "sha256-+N7caL91pVANd7C/aquAneRTjBQenCwaEKqj+3qkjxc= sha384-GQR7GyHPEwwEVph9gGYWEWvMYxkITwcOjieehbPidXZrybuQyw9cpDkjnWo1tj/w sha512-zyPM+8AxyLuECEXjb9w6Z2Sy8zmJdkfTWQphcvAb8AU4ZdkCqLmyjmOs/QQlpfKDe0wdOLyR3V9QgTDDlxtVlQ==" + "filePath": "public/build/3651.4e8f7603e9778e1e9b59.js", + "integrity": "sha256-+N7caL91pVANd7C/aquAneRTjBQenCwaEKqj+3qkjxc= sha384-GQR7GyHPEwwEVph9gGYWEWvMYxkITwcOjieehbPidXZrybuQyw9cpDkjnWo1tj/w sha512-zyPM+8AxyLuECEXjb9w6Z2Sy8zmJdkfTWQphcvAb8AU4ZdkCqLmyjmOs/QQlpfKDe0wdOLyR3V9QgTDDlxtVlQ==" }, { - "FilePath": "public/build/1272.8c79fc44bf7cd993c953.js", - "Integrity": "sha256-d7MRVimV83v4YQ5rdURfTaaFtiedXP3EMLT06gvvBuQ= sha384-8tRpYHQ+sEkZ8ptiIbKAbKPpHTJVnmaWDN56vJoWWUCzV1Q2w034wcJNKDJDJdAs sha512-cIZWoJHusF8qODBOj2j4b18ewcLLMo/92YQSwYQjln2G5e3o1bSO476ox2I2iecJ/tnhQK5j01h9BzTt3dNTrA==" + "filePath": "public/build/1272.8c79fc44bf7cd993c953.js", + "integrity": "sha256-d7MRVimV83v4YQ5rdURfTaaFtiedXP3EMLT06gvvBuQ= sha384-8tRpYHQ+sEkZ8ptiIbKAbKPpHTJVnmaWDN56vJoWWUCzV1Q2w034wcJNKDJDJdAs sha512-cIZWoJHusF8qODBOj2j4b18ewcLLMo/92YQSwYQjln2G5e3o1bSO476ox2I2iecJ/tnhQK5j01h9BzTt3dNTrA==" }, { - "FilePath": "public/build/6902.070074e8f5a989b8f4c3.js", - "Integrity": "sha256-TMo/uTZueyEHtkBzlLZzhwYKWF0epE4qbouo5xcwZkU= sha384-xylZJMtJ7+EsUBBdQZvPh+BeHJ3BnfclqI2vx/8QC9jvfYe/lhRsWW9OMJsxE/Aq sha512-EOmf+KZQMFPoTWAROL8bBLFfHhgvDH8ONycq37JaV7lz+sQOTaWBN2ZD0F/mMdOD5zueTg/Y1RAUP6apoEcHNQ==" + "filePath": "public/build/6902.070074e8f5a989b8f4c3.js", + "integrity": "sha256-TMo/uTZueyEHtkBzlLZzhwYKWF0epE4qbouo5xcwZkU= sha384-xylZJMtJ7+EsUBBdQZvPh+BeHJ3BnfclqI2vx/8QC9jvfYe/lhRsWW9OMJsxE/Aq sha512-EOmf+KZQMFPoTWAROL8bBLFfHhgvDH8ONycq37JaV7lz+sQOTaWBN2ZD0F/mMdOD5zueTg/Y1RAUP6apoEcHNQ==" }, { - "FilePath": "public/build/app.0439db6f56ee4aa501b2.js", - "Integrity": "sha256-q6muaKY7BuN2Ff+00aw69628MXatcFnLNzWRnAD98DI= sha384-gv6lAbkngOHR05bvyOR8dm/J3wIjQQWSjyxK7W8vt2rG9uxcjvvDQV7aI6YbUhfX sha512-o/0mSlJ/OoqrpGdOIWCE3ZCe8n+qqLbgNCERtx9G8FIzsv++CvIWSGbbILjOTGfnEfEQWcKMH0macVpVBSe1Og==" + "filePath": "public/build/app.0439db6f56ee4aa501b2.js", + "integrity": "sha256-q6muaKY7BuN2Ff+00aw69628MXatcFnLNzWRnAD98DI= sha384-gv6lAbkngOHR05bvyOR8dm/J3wIjQQWSjyxK7W8vt2rG9uxcjvvDQV7aI6YbUhfX sha512-o/0mSlJ/OoqrpGdOIWCE3ZCe8n+qqLbgNCERtx9G8FIzsv++CvIWSGbbILjOTGfnEfEQWcKMH0macVpVBSe1Og==" } ], - "CSSDark": "public/build/grafana.dark.a28b24b45b2bbcc628cc.css", - "CSSLight": "public/build/grafana.light.3572f6d5f8b7daa8d8d0.css" + "dark": "public/build/grafana.dark.a28b24b45b2bbcc628cc.css", + "light": "public/build/grafana.light.3572f6d5f8b7daa8d8d0.css" + }`, string(dto)) + + assets.SetContentDeliveryURL("https://grafana-assets.grafana.net/grafana/10.3.0-64123/") + + dto, err = json.MarshalIndent(assets, "", " ") + require.NoError(t, err) + //fmt.Printf("%s\n", string(dto)) + + require.JSONEq(t, `{ + "cdn": "https://grafana-assets.grafana.net/grafana/10.3.0-64123/", + "jsFiles": [ + { + "filePath": "https://grafana-assets.grafana.net/grafana/10.3.0-64123/public/build/runtime.20ed8c01880b812ed29f.js", + "integrity": "sha256-rcdxIHk6cWgu4jiFa1a+pWlileYD/R72GaS8ZACBUdw= sha384-I/VJZQkt+TuJTvu61ihdWPds7EHfLrW5CxeQ0x9gtSqoPg9Z17Uawz1yoYaTdxqQ sha512-4CPAbh4KdTmGxHoQw4pgpYmgAquupVfwfo6UBV2cGU3vGFnEwkhq320037ETwWs+n9xB/bAMOvrdabp1SA1+8g==" + }, + { + "filePath": "https://grafana-assets.grafana.net/grafana/10.3.0-64123/public/build/3951.4e474348841d792ab1ba.js", + "integrity": "sha256-dHqXXTRA3osYhHr9rol8hOV0nC4VP0pr5tbMp5VD95Q= sha384-4QJaSTibnxdYeYsLnmXtd1+If6IkAmXlLR0uYHN5+N+fS0FegHRH7MIFaRGjiO1B sha512-vRLEeEGbxBCx0z+l/m14fSK49reqWGA9zQzsCrD+TQQBmP07YIoRPwopMMyxtKljbbRFV0bW2bUZ7ZvzOZYoIQ==" + }, + { + "filePath": "https://grafana-assets.grafana.net/grafana/10.3.0-64123/public/build/3651.4e8f7603e9778e1e9b59.js", + "integrity": "sha256-+N7caL91pVANd7C/aquAneRTjBQenCwaEKqj+3qkjxc= sha384-GQR7GyHPEwwEVph9gGYWEWvMYxkITwcOjieehbPidXZrybuQyw9cpDkjnWo1tj/w sha512-zyPM+8AxyLuECEXjb9w6Z2Sy8zmJdkfTWQphcvAb8AU4ZdkCqLmyjmOs/QQlpfKDe0wdOLyR3V9QgTDDlxtVlQ==" + }, + { + "filePath": "https://grafana-assets.grafana.net/grafana/10.3.0-64123/public/build/1272.8c79fc44bf7cd993c953.js", + "integrity": "sha256-d7MRVimV83v4YQ5rdURfTaaFtiedXP3EMLT06gvvBuQ= sha384-8tRpYHQ+sEkZ8ptiIbKAbKPpHTJVnmaWDN56vJoWWUCzV1Q2w034wcJNKDJDJdAs sha512-cIZWoJHusF8qODBOj2j4b18ewcLLMo/92YQSwYQjln2G5e3o1bSO476ox2I2iecJ/tnhQK5j01h9BzTt3dNTrA==" + }, + { + "filePath": "https://grafana-assets.grafana.net/grafana/10.3.0-64123/public/build/6902.070074e8f5a989b8f4c3.js", + "integrity": "sha256-TMo/uTZueyEHtkBzlLZzhwYKWF0epE4qbouo5xcwZkU= sha384-xylZJMtJ7+EsUBBdQZvPh+BeHJ3BnfclqI2vx/8QC9jvfYe/lhRsWW9OMJsxE/Aq sha512-EOmf+KZQMFPoTWAROL8bBLFfHhgvDH8ONycq37JaV7lz+sQOTaWBN2ZD0F/mMdOD5zueTg/Y1RAUP6apoEcHNQ==" + }, + { + "filePath": "https://grafana-assets.grafana.net/grafana/10.3.0-64123/public/build/app.0439db6f56ee4aa501b2.js", + "integrity": "sha256-q6muaKY7BuN2Ff+00aw69628MXatcFnLNzWRnAD98DI= sha384-gv6lAbkngOHR05bvyOR8dm/J3wIjQQWSjyxK7W8vt2rG9uxcjvvDQV7aI6YbUhfX sha512-o/0mSlJ/OoqrpGdOIWCE3ZCe8n+qqLbgNCERtx9G8FIzsv++CvIWSGbbILjOTGfnEfEQWcKMH0macVpVBSe1Og==" + } + ], + "dark": "https://grafana-assets.grafana.net/grafana/10.3.0-64123/public/build/grafana.dark.a28b24b45b2bbcc628cc.css", + "light": "https://grafana-assets.grafana.net/grafana/10.3.0-64123/public/build/grafana.light.3572f6d5f8b7daa8d8d0.css" + }`, string(dto)) +} + +func TestReadWebassetsFromCDN(t *testing.T) { + t.Skip() + + assets, err := readWebAssetsFromCDN(context.Background(), "https://grafana-assets.grafana.net/grafana/10.3.0-64123/") + require.NoError(t, err) + + dto, err := json.MarshalIndent(assets, "", " ") + require.NoError(t, err) + //fmt.Printf("%s\n", string(dto)) + + require.JSONEq(t, `{ + "cdn": "https://grafana-assets.grafana.net/grafana/10.3.0-64123/", + "jsFiles": [ + { + "filePath": "https://grafana-assets.grafana.net/grafana/10.3.0-64123/public/build/runtime.6d702760ddd47772f116.js", + "integrity": "sha256-6tSxwMwqd9McukcH+i56v1v+8JsVlMXPWKUCIK30yK8= sha384-dfRWJ5QfPAiQKJ9fUugmeXVdRSx8OS3XUdkEyEhxkm9CZQf9KeUyUe6fGV7VL7s9 sha512-0kjFCSBeQtdS3F9B/uqX45KMMUffYpsU7Ve7AYjy75HiBzovxRGG4hWPZD7d4Gha0Y3Oj4AmZA37TJoafptlRQ==" + }, + { + "filePath": "https://grafana-assets.grafana.net/grafana/10.3.0-64123/public/build/7653.f5c70a70add3b711f560.js", + "integrity": "sha256-p65DYfZPt9NU7vDwlxW+sY9sK+wQ9tJgTGlCJt+LvxY= sha384-P1TDQw3ZJ4X6Fiyn6UpLpVuHq+UW3zKRUM6U0vjucSl/bjFmQJfGR9XE64uEn6sJ sha512-sPqhDs/mWUBL6txtyoTdlgyZvVfdttUAXdV39aEroYpSnl/uEoLIcNBem5mNxoh4ut4TpSb9hlW6tTD7QV07/g==" + }, + { + "filePath": "https://grafana-assets.grafana.net/grafana/10.3.0-64123/public/build/182.0b85a6da60c3ae0a9093.js", + "integrity": "sha256-4vJBytomvJYkSsXlAo7BXDiXRsi5JVWBosIZSMCYlqs= sha384-MWfyWG85/+OvsA4E9CvG1NGiSzrp/EH37Xd/+qfdMFKmvAEGzGx9N/4xF+3N3/yj sha512-j1h6qobFAJYU+7QFdcChEeHa/FPXuArEsHJuXSYtaqrDU7oNHyW1PqFz6kNUwqE674Hutl93EeY+UsUlpZgZZQ==" + }, + { + "filePath": "https://grafana-assets.grafana.net/grafana/10.3.0-64123/public/build/8781.91ede282a7f6078508e7.js", + "integrity": "sha256-b68VAYMTugwWaHtffKI4qCMSWTN/fg0xQv+MnSILQgg= sha384-ptDkcAAAQhuG9Mhvs6gvGIp0HIjCfAP+ysaMltIr3L5alN6Ki71Si/zO6C70YArC sha512-N5tkcDgTPcNvQymegqnx0syp0kS7wVzPnt7i5KSu/RAi6cfM9XiRfz7bZh6fcZAJxApvpL1OJhUQQwPFFBN4ZA==" + }, + { + "filePath": "https://grafana-assets.grafana.net/grafana/10.3.0-64123/public/build/3958.1d29ae9e8eb421432f48.js", + "integrity": "sha256-9c+QGDOI8HtAzVBLA3nJOOU+LzhoENAhIEw7gGSkgWY= sha384-Y05zEdrM/ab9jzGH6segO9GyE8OTV5RvWPZFgynXX4XgvMOyWJcySqwW4RoIVo6P sha512-+ro4iXipgz1zUySd8oMbOY6XX+RjP4gi8bksFNjJGiLQOHVb/EKZKDj5UBeIE96XMd1AoEvZdymCvaft3d8oeA==" + }, + { + "filePath": "https://grafana-assets.grafana.net/grafana/10.3.0-64123/public/build/app.18e8d3e07edcc1356a6a.js", + "integrity": "sha256-ueeH8P/rDaft7jtzRmTN4UpNtiPfhzYa7c1VbBiRLTo= sha384-SijeOWlmIMzm/WNVg5e+yMieef6LOFXMu8d2laBtaY/2m/fviGI+8W55jazWzb+C sha512-qr5MoBZ4wNTCm6aRQ5/mglO8gShmKFpvr066SJgKyAJA4j8cK0snL2XhubUNxND+KkpKAnRe7EjsHYd28/uvkw==" + } + ], + "dark": "https://grafana-assets.grafana.net/grafana/10.3.0-64123/public/build/grafana.dark.b44253d019cd9cb46428.css", + "light": "https://grafana-assets.grafana.net/grafana/10.3.0-64123/public/build/grafana.light.e8e11c59b604d62836be.css" }`, string(dto)) } diff --git a/pkg/middleware/middleware_test.go b/pkg/middleware/middleware_test.go index 3842f6d519d..722c9f09335 100644 --- a/pkg/middleware/middleware_test.go +++ b/pkg/middleware/middleware_test.go @@ -150,9 +150,9 @@ func TestMiddlewareContext(t *testing.T) { Settings: &dtos.FrontendSettingsDTO{}, NavTree: &navtree.NavTreeRoot{}, Assets: &dtos.EntryPointAssets{ - JSFiles: []dtos.EntryPointAsset{}, - CSSDark: "dark.css", - CSSLight: "light.css", + JSFiles: []dtos.EntryPointAsset{}, + Dark: "dark.css", + Light: "light.css", }, } t.Log("Calling HTML", "data", data) diff --git a/pkg/middleware/recovery.go b/pkg/middleware/recovery.go index 3d23c3e5ed3..7d339eefe32 100644 --- a/pkg/middleware/recovery.go +++ b/pkg/middleware/recovery.go @@ -27,6 +27,7 @@ import ( "github.com/grafana/grafana/pkg/api/webassets" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/contexthandler" + "github.com/grafana/grafana/pkg/services/licensing" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/web" ) @@ -104,7 +105,7 @@ func function(pc uintptr) []byte { // Recovery returns a middleware that recovers from any panics and writes a 500 if there was one. // While Martini is in development mode, Recovery will also output the panic as HTML. -func Recovery(cfg *setting.Cfg) web.Middleware { +func Recovery(cfg *setting.Cfg, license licensing.Licensing) web.Middleware { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { c := web.FromContext(req.Context()) @@ -137,7 +138,7 @@ func Recovery(cfg *setting.Cfg) web.Middleware { return } - assets, _ := webassets.GetWebAssets(cfg) + assets, _ := webassets.GetWebAssets(req.Context(), cfg, license) if assets == nil { assets = &dtos.EntryPointAssets{JSFiles: []dtos.EntryPointAsset{}} } @@ -146,7 +147,7 @@ func Recovery(cfg *setting.Cfg) web.Middleware { Title string AppTitle string AppSubUrl string - Theme string + ThemeType string ErrorMsg string Assets *dtos.EntryPointAssets }{"Server Error", "Grafana", cfg.AppSubURL, cfg.DefaultTheme, "", assets} diff --git a/pkg/middleware/recovery_test.go b/pkg/middleware/recovery_test.go index 6b19d9dfdb8..85df5feaed8 100644 --- a/pkg/middleware/recovery_test.go +++ b/pkg/middleware/recovery_test.go @@ -11,6 +11,7 @@ import ( "github.com/grafana/grafana/pkg/services/authn" "github.com/grafana/grafana/pkg/services/authn/authntest" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" + "github.com/grafana/grafana/pkg/services/licensing" "github.com/grafana/grafana/pkg/services/user/usertest" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/web" @@ -62,7 +63,7 @@ func recoveryScenario(t *testing.T, desc string, url string, fn scenarioFunc) { require.NoError(t, err) sc.m = web.New() - sc.m.UseMiddleware(Recovery(cfg)) + sc.m.UseMiddleware(Recovery(cfg, &licensing.OSSLicensingService{})) sc.m.Use(AddDefaultResponseHeaders(cfg)) sc.m.UseMiddleware(web.Renderer(viewsPath, "[[", "]]")) diff --git a/pkg/services/contexthandler/model/model.go b/pkg/services/contexthandler/model/model.go index 3f8248f836e..e7b9ebc8c78 100644 --- a/pkg/services/contexthandler/model/model.go +++ b/pkg/services/contexthandler/model/model.go @@ -43,7 +43,7 @@ func (ctx *ReqContext) Handle(cfg *setting.Cfg, status int, title string, err er Title string AppTitle string AppSubUrl string - Theme string + ThemeType string ErrorMsg error }{title, "Grafana", cfg.AppSubURL, "dark", nil} diff --git a/public/app/app.ts b/public/app/app.ts index c2975ee5b01..9535e431516 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -56,6 +56,7 @@ import { initIconCache } from './core/icons/iconBundle'; import { initializeI18n } from './core/internationalization'; import { interceptLinkClicks } from './core/navigation/patch/interceptLinkClicks'; import { ModalManager } from './core/services/ModalManager'; +import { NewFrontendAssetsChecker } from './core/services/NewFrontendAssetsChecker'; import { backendSrv } from './core/services/backend_srv'; import { contextSrv } from './core/services/context_srv'; import { Echo } from './core/services/echo/Echo'; @@ -218,6 +219,8 @@ export class GrafanaApp { const queryParams = locationService.getSearchObject(); const chromeService = new AppChromeService(); const keybindingsService = new KeybindingSrv(locationService, chromeService); + const newAssetsChecker = new NewFrontendAssetsChecker(); + newAssetsChecker.start(); // Read initial kiosk mode from url at app startup chromeService.setKioskModeFromUrl(queryParams.kiosk); @@ -234,6 +237,7 @@ export class GrafanaApp { location: locationService, chrome: chromeService, keybindings: keybindingsService, + newAssetsChecker, config, }; diff --git a/public/app/core/context/GrafanaContext.ts b/public/app/core/context/GrafanaContext.ts index 7acf311c368..efd14746c88 100644 --- a/public/app/core/context/GrafanaContext.ts +++ b/public/app/core/context/GrafanaContext.ts @@ -5,6 +5,7 @@ import { LocationService } from '@grafana/runtime/src/services/LocationService'; import { BackendSrv } from '@grafana/runtime/src/services/backendSrv'; import { AppChromeService } from '../components/AppChrome/AppChromeService'; +import { NewFrontendAssetsChecker } from '../services/NewFrontendAssetsChecker'; import { KeybindingSrv } from '../services/keybindingSrv'; export interface GrafanaContextType { @@ -13,6 +14,7 @@ export interface GrafanaContextType { config: GrafanaConfig; chrome: AppChromeService; keybindings: KeybindingSrv; + newAssetsChecker: NewFrontendAssetsChecker; } export const GrafanaContext = React.createContext(undefined); diff --git a/public/app/core/services/NewFrontendAssetsChecker.test.ts b/public/app/core/services/NewFrontendAssetsChecker.test.ts new file mode 100644 index 00000000000..33a45ec0640 --- /dev/null +++ b/public/app/core/services/NewFrontendAssetsChecker.test.ts @@ -0,0 +1,49 @@ +import { locationService, setBackendSrv, BackendSrv } from '@grafana/runtime'; + +import { NewFrontendAssetsChecker } from './NewFrontendAssetsChecker'; + +describe('NewFrontendAssetsChecker', () => { + const backendApiGet = jest.fn().mockReturnValue(Promise.resolve({})); + const locationReload = jest.fn(); + + const originalLocation = window.location; + + beforeAll(() => { + Object.defineProperty(window, 'location', { + configurable: true, + value: { reload: locationReload }, + }); + }); + + afterAll(() => { + Object.defineProperty(window, 'location', { configurable: true, value: originalLocation }); + }); + + setBackendSrv({ + get: backendApiGet, + } as unknown as BackendSrv); + + it('Should skip update checks if below interval', () => { + const checker = new NewFrontendAssetsChecker(); + checker.start(); + + locationService.push('/d/123'); + + expect(backendApiGet).toHaveBeenCalledTimes(0); + }); + + it('Should do update check when changing dashboard or going home', async () => { + const checker = new NewFrontendAssetsChecker(0); + checker.start(); + + locationService.push('/d/asd'); + locationService.push('/d/other'); + locationService.push('/d/other?viewPanel=2'); + locationService.push('/ignored'); + locationService.push('/ignored?asd'); + locationService.push('/ignored/sub'); + locationService.push('/home'); + + expect(backendApiGet).toHaveBeenCalledTimes(2); + }); +}); diff --git a/public/app/core/services/NewFrontendAssetsChecker.ts b/public/app/core/services/NewFrontendAssetsChecker.ts new file mode 100644 index 00000000000..6a375add6fc --- /dev/null +++ b/public/app/core/services/NewFrontendAssetsChecker.ts @@ -0,0 +1,112 @@ +import { Location } from 'history'; +import { isEqual } from 'lodash'; + +import { getBackendSrv, getGrafanaLiveSrv, locationService, reportInteraction } from '@grafana/runtime'; + +export class NewFrontendAssetsChecker { + private hasUpdates = false; + private previous?: FrontendAssetsAPIDTO; + private interval: number; + private checked = Date.now(); + private prevLocationPath = ''; + + public constructor(interval?: number) { + // Default to never check for updates if last check was 5 minutes ago + this.interval = interval ?? 1000 * 60 * 5; + } + + public start() { + // Subscribe to live connection state changes and check for new assets when re-connected + const live = getGrafanaLiveSrv(); + + if (live) { + live.getConnectionState().subscribe((connected) => { + if (connected) { + this._checkForUpdates(); + } + }); + } + + // Subscribe to location changes + locationService.getHistory().listen(this.locationUpdated.bind(this)); + this.prevLocationPath = locationService.getLocation().pathname; + } + + /** + * Tries to detect some navigation events where it's safe to trigger a reload + */ + private locationUpdated(location: Location) { + if (this.prevLocationPath === location.pathname) { + return; + } + + const newLocationSegments = location.pathname.split('/'); + + // We are going to home + if (newLocationSegments[1] === '/' && this.prevLocationPath !== '/') { + this.reloadIfUpdateDetected(); + } + // Moving to dashboard (or changing dashboards) + else if (newLocationSegments[1] === 'd') { + this.reloadIfUpdateDetected(); + } + // Track potential page change + else if (this.hasUpdates) { + reportInteraction('new_frontend_assets_reload_ignored', { + newLocation: location.pathname, + prevLocation: this.prevLocationPath, + }); + } + + this.prevLocationPath = location.pathname; + } + + private async _checkForUpdates() { + if (this.hasUpdates) { + return; + } + + // Don't check too often + if (Date.now() - this.checked < this.interval) { + return; + } + + this.checked = Date.now(); + + const previous = this.previous; + const result: FrontendAssetsAPIDTO = await getBackendSrv().get('/api/frontend/assets'); + + if (previous && !isEqual(previous, result)) { + this.hasUpdates = true; + + // Report that we detected new assets + reportInteraction('new_frontend_assets_detected', { + assets: previous.assets !== result.assets, + plugins: previous.plugins !== result.plugins, + version: previous.version !== result.version, + flags: previous.flags !== result.flags, + }); + } + + this.previous = result; + } + + /** This is called on page navigation events */ + public reloadIfUpdateDetected() { + if (this.hasUpdates) { + // Report that we detected new assets + reportInteraction('new_frontend_assets_reload', {}); + window.location.reload(); + } + + // Async check if the assets have changed + this._checkForUpdates(); + } +} + +interface FrontendAssetsAPIDTO { + assets: string; + flags: string; + plugins: string; + version: string; +} diff --git a/public/app/core/services/theme.ts b/public/app/core/services/theme.ts index b19b04f3242..6368aa07f76 100644 --- a/public/app/core/services/theme.ts +++ b/public/app/core/services/theme.ts @@ -18,7 +18,7 @@ export async function changeTheme(themeId: string, runtimeOnly?: boolean) { if (oldTheme.colors.mode !== newTheme.colors.mode) { const newCssLink = document.createElement('link'); newCssLink.rel = 'stylesheet'; - newCssLink.href = config.bootData.themePaths[newTheme.colors.mode]; + newCssLink.href = config.bootData.assets[newTheme.colors.mode]; newCssLink.onload = () => { // Remove old css file const bodyLinks = document.getElementsByTagName('link'); diff --git a/public/test/mocks/getGrafanaContextMock.ts b/public/test/mocks/getGrafanaContextMock.ts index feb0155cabd..fd6aaec843e 100644 --- a/public/test/mocks/getGrafanaContextMock.ts +++ b/public/test/mocks/getGrafanaContextMock.ts @@ -2,6 +2,7 @@ import { GrafanaConfig } from '@grafana/data'; import { LocationService } from '@grafana/runtime'; import { AppChromeService } from 'app/core/components/AppChrome/AppChromeService'; import { GrafanaContextType } from 'app/core/context/GrafanaContext'; +import { NewFrontendAssetsChecker } from 'app/core/services/NewFrontendAssetsChecker'; import { backendSrv } from 'app/core/services/backend_srv'; import { KeybindingSrv } from 'app/core/services/keybindingSrv'; @@ -20,6 +21,10 @@ export function getGrafanaContextMock(overrides: Partial = { setupDashboardBindings: jest.fn(), setupTimeRangeBindings: jest.fn(), } as unknown as KeybindingSrv, + newAssetsChecker: { + start: jest.fn(), + reloadIfUpdateDetected: jest.fn(), + } as unknown as NewFrontendAssetsChecker, ...overrides, }; } diff --git a/public/views/error.html b/public/views/error.html index d3cdee0f7fb..1eb195cd125 100644 --- a/public/views/error.html +++ b/public/views/error.html @@ -10,17 +10,17 @@ - [[ if eq .Theme "light" ]] - - [[ else if eq .Theme "dark" ]] - + [[ if eq .ThemeType "light" ]] + + [[ else ]] + [[ end ]] - +