Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ability to use JS file for UI configuration (#123 from jaeger-ui) #2707

Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions cmd/query/app/fixture/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
<meta charset="UTF-8">
<base href="/" data-inject-target="BASE_URL"/>
<title>Test Page</title>
<!-- JAEGER_CONFIG=DEFAULT_CONFIG; -->
<!-- JAEGER_VERSION=DEFAULT_VERSION; -->
<!--
// JAEGER_CONFIG_JS
// the line above may be replaced by user-provided JS file that should define a UIConfig function.
JAEGER_CONFIG=DEFAULT_CONFIG;
JAEGER_VERSION=DEFAULT_VERSION;
-->
</html>
9 changes: 9 additions & 0 deletions cmd/query/app/fixture/ui-config-hotreload.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
function UIConfig(){
return {
menu: [
{
label: "About Jaeger"
}
]
}
}
10 changes: 10 additions & 0 deletions cmd/query/app/fixture/ui-config-malformed.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
() => {
return {
menu: [
{
label: "GitHub",
url: "https://github.com/jaegertracing/jaeger"
}
]
}
}
10 changes: 10 additions & 0 deletions cmd/query/app/fixture/ui-config-menu.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
function UIConfig(){
return {
menu: [
{
label: "GitHub",
url: "https://github.com/jaegertracing/jaeger"
}
]
}
}
5 changes: 5 additions & 0 deletions cmd/query/app/fixture/ui-config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
function UIConfig(){
return {
x: "y"
}
}
49 changes: 35 additions & 14 deletions cmd/query/app/static_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
package app

import (
"bytes"
"encoding/json"
"fmt"
"io/ioutil"
Expand All @@ -39,6 +40,7 @@ var (

// The following patterns are searched and replaced in the index.html as a way of customizing the UI.
configPattern = regexp.MustCompile("JAEGER_CONFIG *= *DEFAULT_CONFIG;")
configJsPattern = regexp.MustCompile(`(?im)(^\s+)\/\/.*JAEGER_CONFIG_JS.*\n.*`)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
configJsPattern = regexp.MustCompile(`(?im)(^\s+)\/\/.*JAEGER_CONFIG_JS.*\n.*`)
configJsPattern = regexp.MustCompile(`(?im)(^\s+)\/\/\s*JAEGER_CONFIG_JS.*\n.*`)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what is the purpose of trailing .*? Also, with the m mode, does .*\n guarantee single-line match?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.* - my bad, you are correct \s+ is best fits.
m is for multiline search. I assume that we must replace 2 lines: line with JAEGER_CONFIG_JS and following comment on the next line

// JAEGER_CONFIG_JS
// the line above may be replaced by user-provided JS file that should define a UIConfig function.

versionPattern = regexp.MustCompile("JAEGER_VERSION *= *DEFAULT_VERSION;")
basePathPattern = regexp.MustCompile(`<base href="/"`) // Note: tag is not closed
)
Expand Down Expand Up @@ -72,6 +74,12 @@ type StaticAssetsHandlerOptions struct {
Logger *zap.Logger
}

// UIConfigOptions define options for injecting config in index.html in loadAndEnrichIndexHTML
type UIConfigOptions struct {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • "options" typically refers to inputs, this is used as output
  • does not need to be public
Suggested change
type UIConfigOptions struct {
type loadedConfig struct {

regexp *regexp.Regexp
config []byte
}

// NewStaticAssetsHandler returns a StaticAssetsHandler
func NewStaticAssetsHandler(staticAssetsRoot string, options StaticAssetsHandlerOptions) (*StaticAssetsHandler, error) {
assetsFS := ui.StaticFiles
Expand Down Expand Up @@ -105,17 +113,11 @@ func loadAndEnrichIndexHTML(open func(string) (http.File, error), options Static
return nil, fmt.Errorf("cannot load index.html: %w", err)
}
// replace UI config
configString := "JAEGER_CONFIG = DEFAULT_CONFIG"
if config, err := loadUIConfig(options.UIConfigPath); err != nil {
if configObject, err := loadUIConfig(options.UIConfigPath); err != nil {
return nil, err
} else if config != nil {
// TODO if we want to support other config formats like YAML, we need to normalize `config` to be
// suitable for json.Marshal(). For example, YAML parser may return a map that has keys of type
// interface{}, and json.Marshal() is unable to serialize it.
bytes, _ := json.Marshal(config)
configString = fmt.Sprintf("JAEGER_CONFIG = %v", string(bytes))
} else if configObject != nil {
indexBytes = configObject.regexp.ReplaceAll(indexBytes, append([]byte(`${1}`), configObject.config...))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am confused by ${1} - is it just repeating whitespace? If we match on // JAEGER... and replace just that, the whitespace becomes irrelevant.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you are correct, it's for white spaces for function UIConfig(). To save origin indent 😶 . Will remove it.

}
indexBytes = configPattern.ReplaceAll(indexBytes, []byte(configString+";"))
// replace Jaeger version
versionJSON, _ := json.Marshal(version.Get())
versionString := fmt.Sprintf("JAEGER_VERSION = %s;", string(versionJSON))
Expand Down Expand Up @@ -210,30 +212,49 @@ func loadIndexHTML(open func(string) (http.File, error)) ([]byte, error) {
return indexBytes, nil
}

func loadUIConfig(uiConfig string) (map[string]interface{}, error) {
func loadUIConfig(uiConfig string) (*UIConfigOptions, error) {
if uiConfig == "" {
return nil, nil
}
ext := filepath.Ext(uiConfig)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for better readability, I would move this next to the switch condition:

ext := filepath.Ext(uiConfig)
switch strings.ToLower(ext) {

bytes, err := ioutil.ReadFile(filepath.Clean(uiConfig))
bytesConfig, err := ioutil.ReadFile(filepath.Clean(uiConfig))
if err != nil {
return nil, fmt.Errorf("cannot read UI config file %v: %w", uiConfig, err)
}

var c map[string]interface{}
var r []byte
var re *regexp.Regexp
var unmarshal func([]byte, interface{}) error

switch strings.ToLower(ext) {
case ".json":
unmarshal = json.Unmarshal
re = configPattern
case ".js":
r = bytes.TrimSpace(bytesConfig)
re = configJsPattern
if !bytes.Contains(r, []byte("function UIConfig(){")) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps it's better to use regex here? Especially the part (){, without space before {, is a matter of style/formatting.

return nil, fmt.Errorf("wrong JS function format in UI config file format %v", uiConfig)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

more self-explanatory error:

Suggested change
return nil, fmt.Errorf("wrong JS function format in UI config file format %v", uiConfig)
return nil, fmt.Errorf("UI config file must define function UIConfig(): %v", uiConfig)

}
default:
return nil, fmt.Errorf("unrecognized UI config file format %v", uiConfig)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return nil, fmt.Errorf("unrecognized UI config file format %v", uiConfig)
return nil, fmt.Errorf("unrecognized UI config file format, expecting .js or .json file: %v", uiConfig)

}

if err := unmarshal(bytes, &c); err != nil {
return nil, fmt.Errorf("cannot parse UI config file %v: %w", uiConfig, err)
if unmarshal != nil {
if err := unmarshal(bytesConfig, &c); err != nil {
return nil, fmt.Errorf("cannot parse UI config file %v: %w", uiConfig, err)
}
// TODO if we want to support other config formats like YAML, we need to normalize `config` to be
// suitable for json.Marshal(). For example, YAML parser may return a map that has keys of type
// interface{}, and json.Marshal() is unable to serialize it.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suggest removing this. We don't have any plans to support anything other than JSON and JS. I would move the unmarshal code directly under case ".json": to simplify the logic.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can also return directly from switch branches instead of maintaining global-like r, re, c variables.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

if r, err = json.Marshal(c); err != nil {
return nil, fmt.Errorf("cannot encode UI config file %v: %w", uiConfig, err)
}
r = append([]byte("JAEGER_CONFIG = "), append(r, byte(';'))...)
}
return c, nil

return &UIConfigOptions{regexp: re, config: r}, nil
}

// RegisterRoutes registers routes for this handler on the given router
Expand Down
132 changes: 90 additions & 42 deletions cmd/query/app/static_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,13 @@
package app

import (
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"net/http/httptest"
"os"
"regexp"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -117,57 +119,67 @@ func TestNewStaticAssetsHandlerErrors(t *testing.T) {

// This test is potentially intermittent
func TestHotReloadUIConfigTempFile(t *testing.T) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does reload functionality actually depend on the type of file being loaded? It seems it only complicates the test without any real benefits.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably no, but to keep consistency I did these changes. Will revert this back

tmpfile, err := ioutil.TempFile("", "ui-config-hotreload.*.json")
assert.NoError(t, err)
run := func(description string, extension string) {
t.Run(description, func(t *testing.T) {
tmpfile, err := ioutil.TempFile("", "ui-config-hotreload.*."+extension)
assert.NoError(t, err)

tmpFileName := tmpfile.Name()
defer os.Remove(tmpFileName)
tmpFileName := tmpfile.Name()
defer os.Remove(tmpFileName)

content, err := ioutil.ReadFile("fixture/ui-config-hotreload.json")
assert.NoError(t, err)
content, err := ioutil.ReadFile("fixture/ui-config-hotreload." + extension)
assert.NoError(t, err)

err = ioutil.WriteFile(tmpFileName, content, 0644)
assert.NoError(t, err)
err = ioutil.WriteFile(tmpFileName, content, 0644)
assert.NoError(t, err)

h, err := NewStaticAssetsHandler("fixture", StaticAssetsHandlerOptions{
UIConfigPath: tmpFileName,
})
assert.NoError(t, err)
h, err := NewStaticAssetsHandler("fixture", StaticAssetsHandlerOptions{
UIConfigPath: tmpFileName,
})
assert.NoError(t, err)

c := string(h.indexHTML.Load().([]byte))
assert.Contains(t, c, "About Jaeger")

c := string(h.indexHTML.Load().([]byte))
assert.Contains(t, c, "About Jaeger")
newContent := strings.Replace(string(content), "About Jaeger", "About a new Jaeger", 1)
err = ioutil.WriteFile(tmpFileName, []byte(newContent), 0644)
assert.NoError(t, err)

newContent := strings.Replace(string(content), "About Jaeger", "About a new Jaeger", 1)
err = ioutil.WriteFile(tmpFileName, []byte(newContent), 0644)
assert.NoError(t, err)
done := make(chan bool)
go func() {
for {
i := string(h.indexHTML.Load().([]byte))

done := make(chan bool)
go func() {
for {
i := string(h.indexHTML.Load().([]byte))
if strings.Contains(i, "About a new Jaeger") {
done <- true
}
time.Sleep(10 * time.Millisecond)
}
}()

if strings.Contains(i, "About a new Jaeger") {
done <- true
select {
case <-done:
assert.Contains(t, string(h.indexHTML.Load().([]byte)), "About a new Jaeger")
case <-time.After(time.Second):
assert.Fail(t, "timed out waiting for the hot reload to kick in")
}
time.Sleep(10 * time.Millisecond)
}
}()

select {
case <-done:
assert.Contains(t, string(h.indexHTML.Load().([]byte)), "About a new Jaeger")
case <-time.After(time.Second):
assert.Fail(t, "timed out waiting for the hot reload to kick in")
})
}

run("json hot reload", "json")
run("js hot reload", "js")
}

func TestLoadUIConfig(t *testing.T) {
type testCase struct {
configFile string
expected map[string]interface{}
expected *UIConfigOptions
expectedError string
}

jsonRegExpPattern := regexp.MustCompile("JAEGER_CONFIG *= *DEFAULT_CONFIG;")
jsRegExpPattern := regexp.MustCompile(`(?im)(^\s+)\/\/.*JAEGER_CONFIG_JS.*\n.*`)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we just use the constants here? Also, do we even need to be comparing which regex is returned? I would rather express the test in terms of what the resulting HTML looks like, not the implementation details of how that HTML is produced.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we just use the constants here?

Sure

Also, do we even need to be comparing which regex is returned?

This is unit test, I believe, and LoadUIConfig is return

type UIConfigOptions struct {
	regexp *regexp.Regexp
	config []byte
}

regexp is part of it...

I would rather express the test in terms of what the resulting HTML looks like, not the implementation details of how that HTML is produced.

Will update TestRegisterStaticHandler to cover JS file config case


run := func(description string, testCase testCase) {
t.Run(description, func(t *testing.T) {
config, err := loadUIConfig(testCase.configFile)
Expand All @@ -181,7 +193,7 @@ func TestLoadUIConfig(t *testing.T) {
}

run("no config", testCase{})
run("invalid config", testCase{
run("invalid json config", testCase{
configFile: "invalid",
expectedError: "cannot read UI config file invalid: open invalid: no such file or directory",
})
Expand All @@ -195,19 +207,55 @@ func TestLoadUIConfig(t *testing.T) {
})
run("json", testCase{
configFile: "fixture/ui-config.json",
expected: map[string]interface{}{"x": "y"},
expected: &UIConfigOptions{
config: []byte(`JAEGER_CONFIG = {"x":"y"};`),
regexp: jsonRegExpPattern,
},
})
c, _ := json.Marshal(map[string]interface{}{
"menu": []interface{}{
map[string]interface{}{
"label": "GitHub",
"url": "https://github.com/jaegertracing/jaeger",
},
},
})
run("json-menu", testCase{
configFile: "fixture/ui-config-menu.json",
expected: map[string]interface{}{
"menu": []interface{}{
map[string]interface{}{
"label": "GitHub",
"url": "https://github.com/jaegertracing/jaeger",
},
},
expected: &UIConfigOptions{
config: append([]byte("JAEGER_CONFIG = "), append(c, byte(';'))...),
regexp: jsonRegExpPattern,
},
})
run("malformed js config", testCase{
configFile: "fixture/ui-config-malformed.js",
expectedError: "wrong JS function format in UI config file format fixture/ui-config-malformed.js",
})
run("js", testCase{
configFile: "fixture/ui-config.js",
expected: &UIConfigOptions{
regexp: jsRegExpPattern,
config: []byte(`function UIConfig(){
return {
x: "y"
}
}`)},
})
run("js-menu", testCase{
configFile: "fixture/ui-config-menu.js",
expected: &UIConfigOptions{
regexp: jsRegExpPattern,
config: []byte(`function UIConfig(){
return {
menu: [
{
label: "GitHub",
url: "https://github.com/jaegertracing/jaeger"
}
]
}
}`)},
})
}

type fakeFile struct {
Expand Down