From 043bbfd50543cfbf3c7744e4dbd4080e543d207a Mon Sep 17 00:00:00 2001 From: brittonhayes Date: Sun, 13 Oct 2024 02:13:24 -0700 Subject: [PATCH 01/15] feat: added support for new substation playground command --- cmd/substation/playground.go | 551 +++++++++++++++++++++++++++++++++++ substation.go | 4 + 2 files changed, 555 insertions(+) create mode 100644 cmd/substation/playground.go diff --git a/cmd/substation/playground.go b/cmd/substation/playground.go new file mode 100644 index 00000000..210322d4 --- /dev/null +++ b/cmd/substation/playground.go @@ -0,0 +1,551 @@ +package main + +import ( + "context" + _ "embed" + "encoding/json" + "fmt" + "html/template" + "log" + "net/http" + "os" + "os/signal" + "syscall" + + "github.com/spf13/cobra" + "github.com/tidwall/gjson" + + "github.com/brexhq/substation/v2" + "github.com/brexhq/substation/v2/message" + "github.com/google/go-jsonnet" +) + +func init() { + rootCmd.AddCommand(playgroundCmd) +} + +var playgroundCmd = &cobra.Command{ + Use: "playground", + Short: "start playground", + Long: `'substation playground' starts a local HTTP server for testing Substation configurations.`, + RunE: runPlayground, +} + +func runPlayground(cmd *cobra.Command, args []string) error { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + mux := http.NewServeMux() + mux.HandleFunc("/", handleIndex) + mux.HandleFunc("/run", handleRun) + mux.HandleFunc("/examples", handleExamples) + + server := &http.Server{ + Addr: ":8080", + Handler: mux, + } + + go func() { + log.Println("Substation playground is running on http://localhost:8080") + if err := server.ListenAndServe(); err != http.ErrServerClosed { + log.Printf("HTTP server error: %v", err) + } + }() + + stop := make(chan os.Signal, 1) + signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) + <-stop + + log.Println("Shutting down playground...") + return server.Shutdown(ctx) +} + +func handleIndex(w http.ResponseWriter, r *http.Request) { + data := struct { + DefaultConfig string + DefaultInput string + }{ + DefaultConfig: demoConf, + DefaultInput: demoEvt, + } + tmpl := template.Must(template.New("index").Parse(indexHTML)) + if err := tmpl.Execute(w, data); err != nil { + log.Printf("Error executing template: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } +} + +func handleRun(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var request struct { + Config string `json:"config"` + Input string `json:"input"` + } + + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + + combinedConfig := fmt.Sprintf(`local sub = %s; + +%s`, substation.Libsonnet, request.Config) + + vm := jsonnet.MakeVM() + jsonString, err := vm.EvaluateAnonymousSnippet("", combinedConfig) + if err != nil { + http.Error(w, fmt.Sprintf("Error evaluating Jsonnet: %v", err), http.StatusBadRequest) + return + } + + var cfg substation.Config + if err := json.Unmarshal([]byte(jsonString), &cfg); err != nil { + http.Error(w, fmt.Sprintf("Invalid configuration: %v", err), http.StatusBadRequest) + return + } + + sub, err := substation.New(r.Context(), cfg) + if err != nil { + http.Error(w, fmt.Sprintf("Error creating Substation instance: %v", err), http.StatusInternalServerError) + return + } + + msgs := []*message.Message{ + message.New().SetData([]byte(request.Input)), + message.New().AsControl(), + } + + result, err := sub.Transform(r.Context(), msgs...) + if err != nil { + http.Error(w, fmt.Sprintf("Error transforming messages: %v", err), http.StatusInternalServerError) + return + } + + var output []string + for _, msg := range result { + if !msg.IsControl() { + output = append(output, gjson.Get(string(msg.Data()), "@this|@pretty").String()) + } + } + + if err := json.NewEncoder(w).Encode(map[string]interface{}{ + "output": output, + }); err != nil { + http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError) + } +} + +func handleExamples(w http.ResponseWriter, r *http.Request) { + examples := map[string]struct { + Config string `json:"config"` + Input string `json:"input"` + }{ + "stringConversion": { + Config: `{ + transforms: [ + sub.tf.time.from.string({ obj: { source_key: 'time', target_key: 'time' }, format: '2006-01-02T15:04:05.000Z' }), + sub.tf.time.to.string({ obj: { source_key: 'time', target_key: 'time' }, format: '2006-01-02T15:04:05' }), + ], +}`, + Input: `{"time":"2024-01-01T01:02:03.123Z"}`, + }, + "numberClamp": { + Config: `{ + transforms: [ + sub.tf.number.maximum({ value: 0 }), + sub.tf.number.minimum({ value: 100 }), + ], +}`, + Input: `-1 +101 +50`, + }, + "arrayFlatten": { + Config: `{ + transforms: [ + sub.tf.obj.cp({ object: { source_key: 'a|@flatten', target_key: 'a' } }), + sub.tf.obj.cp({ object: { source_key: '@pretty' } }), + ], +}`, + Input: `{"a":[1,2,[3,4]]}`, + }, + } + + if err := json.NewEncoder(w).Encode(examples); err != nil { + http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError) + } +} + +const indexHTML = ` + + + + + + + Substation | Playground + + + + + + + + +
+ +
+
+

+ Substation + Playground +

+

A toolkit for routing, normalizing, and enriching security event and audit logs.

+
+
+
+
+ +
+ +
+

Select an example to get started or create your own configuration.

+
+
+
+
+
+
+

Configuration

+

Configure the transformations to be applied to the input event.

+
+
+
+
+
+

Input

+

Paste the JSON event to be processed by Substation here.

+
+
+
+

Output

+

The processed event will appear here after running.

+
+
+
+
+ + + + + +` diff --git a/substation.go b/substation.go index 061910c0..22988bd2 100644 --- a/substation.go +++ b/substation.go @@ -2,6 +2,7 @@ package substation import ( "context" + _ "embed" "encoding/json" "fmt" @@ -10,6 +11,9 @@ import ( "github.com/brexhq/substation/v2/transform" ) +//go:embed substation.libsonnet +var Libsonnet string + var errNoTransforms = fmt.Errorf("no transforms configured") // Config is the core configuration for the application. Custom applications From 4a628cd763959fed79cb8b231220f8c84cbb6b87 Mon Sep 17 00:00:00 2001 From: brittonhayes Date: Sun, 13 Oct 2024 02:27:22 -0700 Subject: [PATCH 02/15] fix: remove unused font and darken h1 to brexhq brand color --- cmd/substation/playground.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/cmd/substation/playground.go b/cmd/substation/playground.go index 210322d4..1536744b 100644 --- a/cmd/substation/playground.go +++ b/cmd/substation/playground.go @@ -191,7 +191,6 @@ const indexHTML = ` @@ -469,56 +403,70 @@ const indexHTML = ` -
- -
-
-

- Substation - Playground -

-

A toolkit for routing, normalizing, and enriching security event and audit logs.

+ +
+
-
- -
- + + +
-

Select an example to get started or create your own configuration.

+

+ Run your configuration, test it, or try a demo. + View examples +

-
-
+ +
-

Configuration

+
+

Configuration

+

Configure the transformations to be applied to the input event.

-

Input

-

Paste the JSON event to be processed by Substation here.

+
+

Input

+ +
+

Paste the message data to be processed by Substation here.

-

Output

-

The processed event will appear here after running.

+
+

Output

+ +
+

The processed message data will appear here after running.

@@ -543,33 +491,30 @@ const indexHTML = ` roundedSelection: false, readOnly: elementId === 'output', renderLineHighlight: 'none', + wordWrap: 'on', }); } - configEditor = createEditor('config', 'jsonnet', ""); - inputEditor = createEditor('input', 'json', ""); - outputEditor = createEditor('output', 'json', '// Output will appear here'); - - // Fetch examples from the API and set default example - fetch('/examples') - .then(response => response.json()) - .then(data => { - examples = data; - // Set the default example to "stringConversion" - document.getElementById('exampleSelector').value = 'stringConversion'; - loadExample(); - }) - .catch(error => console.error('Error fetching examples:', error)); + inputEditor = createEditor('input', 'text', ""); + outputEditor = createEditor('output', 'text', '// Processed message data will appear here'); }); + function changeEditorMode(editorId) { + const editor = editorId === 'input' ? inputEditor : outputEditor; + const id = editorId + "ModeSelector"; + const selector = document.getElementById(id); + const newModel = monaco.editor.createModel(editor.getValue(), selector.value); + editor.setModel(newModel); + } + function runSubstation() { fetch('/run', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ config: configEditor.getValue(), - input: inputEditor.getValue() + input: inputEditor.getValue(), }) }) .then(response => response.json()) @@ -594,17 +539,35 @@ const indexHTML = ` .catch(error => console.error('Error formatting Jsonnet:', error)); } - function loadExample() { - const example = document.getElementById('exampleSelector').value; - if (example in examples) { - configEditor.setValue(examples[example].config); - inputEditor.setValue(examples[example].input); - outputEditor.setValue('// Output will appear here'); - } else if (example === '') { - configEditor.setValue(""); - inputEditor.setValue(""); - outputEditor.setValue('// Output will appear here'); - } + function testSubstation() { + fetch('/test', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + config: configEditor.getValue(), + input: inputEditor.getValue(), + }) + }) + .then(response => response.json()) + .then(data => { + outputEditor.setValue(JSON.stringify(data, null, 2)); + }) + .catch(error => { + outputEditor.setValue('Error: ' + error); + }); + } + + function demoSubstation() { + fetch('/demo') + .then(response => response.json()) + .then(data => { + configEditor.setValue(data.config); + inputEditor.setValue(data.input); + outputEditor.setValue('// Run the demo to see the output'); + }) + .catch(error => { + console.error('Error fetching demo:', error); + }); } From 63b647fa53f9875069cc83dfa84aec5bc6a565fc Mon Sep 17 00:00:00 2001 From: brittonhayes Date: Wed, 16 Oct 2024 12:26:02 -0700 Subject: [PATCH 06/15] chore: add placeholder handle test --- cmd/substation/playground.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/cmd/substation/playground.go b/cmd/substation/playground.go index 3537c559..4445c576 100644 --- a/cmd/substation/playground.go +++ b/cmd/substation/playground.go @@ -39,6 +39,7 @@ func runPlayground(cmd *cobra.Command, args []string) error { mux := http.NewServeMux() mux.HandleFunc("/", handleIndex) mux.HandleFunc("/run", handleRun) + mux.HandleFunc("/test", handleTest) mux.HandleFunc("/demo", handleDemo) mux.HandleFunc("/fmt", handleFmt) @@ -91,6 +92,10 @@ func handleDemo(w http.ResponseWriter, r *http.Request) { } } +func handleTest(w http.ResponseWriter, r *http.Request) { + return +} + func handleRun(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) From 5e3f6172bacc1dc7e7c7ca9a8e9906de252ce35b Mon Sep 17 00:00:00 2001 From: brittonhayes Date: Wed, 16 Oct 2024 13:38:18 -0700 Subject: [PATCH 07/15] feat(playground): share button --- cmd/substation/playground.go | 100 +++++++++++++++++++++++++++++++++-- 1 file changed, 96 insertions(+), 4 deletions(-) diff --git a/cmd/substation/playground.go b/cmd/substation/playground.go index 4445c576..e2685356 100644 --- a/cmd/substation/playground.go +++ b/cmd/substation/playground.go @@ -3,11 +3,13 @@ package main import ( "context" _ "embed" + "encoding/base64" "encoding/json" "fmt" "html/template" "log" "net/http" + "net/url" "os" "os/signal" "strings" @@ -42,6 +44,7 @@ func runPlayground(cmd *cobra.Command, args []string) error { mux.HandleFunc("/test", handleTest) mux.HandleFunc("/demo", handleDemo) mux.HandleFunc("/fmt", handleFmt) + mux.HandleFunc("/share", handleShare) // Add this line server := &http.Server{ Addr: ":8080", @@ -67,9 +70,25 @@ func handleIndex(w http.ResponseWriter, r *http.Request) { data := struct { DefaultConfig string DefaultInput string + DefaultOutput string }{ DefaultConfig: "", DefaultInput: "", + DefaultOutput: "", + } + + // Check for shared data in query string + sharedData := r.URL.Query().Get("share") + if sharedData != "" { + decodedData, err := base64.URLEncoding.DecodeString(sharedData) + if err == nil { + parts := strings.SplitN(string(decodedData), "|", 3) + if len(parts) == 3 { + data.DefaultConfig = parts[0] + data.DefaultInput = parts[1] + data.DefaultOutput = parts[2] + } + } } tmpl := template.Must(template.New("index").Parse(indexHTML)) @@ -187,6 +206,38 @@ func handleFmt(w http.ResponseWriter, r *http.Request) { } } +// Add a new handler for sharing +func handleShare(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var request struct { + Config string `json:"config"` + Input string `json:"input"` + Output string `json:"output"` + } + + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + + // Combine and encode the data + combined := request.Config + "|" + request.Input + "|" + request.Output + encoded := base64.URLEncoding.EncodeToString([]byte(combined)) + + // Create the shareable URL + shareURL := url.URL{ + Path: "/", + RawQuery: "share=" + encoded, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"url": shareURL.String()}) +} + const indexHTML = ` @@ -433,6 +484,7 @@ const indexHTML = ` +

Run your configuration, test it, or try a demo. @@ -479,7 +531,6 @@ const indexHTML = ` From db0ae850107809133e7c75dfff56087fdc3b6d09 Mon Sep 17 00:00:00 2001 From: brittonhayes Date: Wed, 16 Oct 2024 13:45:32 -0700 Subject: [PATCH 08/15] fix: unique substation editor separator --- cmd/substation/playground.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/substation/playground.go b/cmd/substation/playground.go index e2685356..8d80801b 100644 --- a/cmd/substation/playground.go +++ b/cmd/substation/playground.go @@ -82,7 +82,7 @@ func handleIndex(w http.ResponseWriter, r *http.Request) { if sharedData != "" { decodedData, err := base64.URLEncoding.DecodeString(sharedData) if err == nil { - parts := strings.SplitN(string(decodedData), "|", 3) + parts := strings.SplitN(string(decodedData), "{substation-separator}", 3) if len(parts) == 3 { data.DefaultConfig = parts[0] data.DefaultInput = parts[1] @@ -225,7 +225,7 @@ func handleShare(w http.ResponseWriter, r *http.Request) { } // Combine and encode the data - combined := request.Config + "|" + request.Input + "|" + request.Output + combined := request.Config + "{substation-separator}" + request.Input + "{substation-separator}" + request.Output encoded := base64.URLEncoding.EncodeToString([]byte(combined)) // Create the shareable URL From fe22fdcb6859e4b0dcd284eeb8910e5ad97aec0e Mon Sep 17 00:00:00 2001 From: brittonhayes Date: Tue, 22 Oct 2024 17:55:15 -0700 Subject: [PATCH 09/15] feat: added support for running unit tests in substation playground --- cmd/substation/playground.go | 152 +++++++++++++++++++++++++++++++---- 1 file changed, 136 insertions(+), 16 deletions(-) diff --git a/cmd/substation/playground.go b/cmd/substation/playground.go index 8d80801b..f2eaab04 100644 --- a/cmd/substation/playground.go +++ b/cmd/substation/playground.go @@ -14,10 +14,12 @@ import ( "os/signal" "strings" "syscall" + "time" "github.com/spf13/cobra" "github.com/brexhq/substation/v2" + "github.com/brexhq/substation/v2/condition" "github.com/brexhq/substation/v2/message" "github.com/google/go-jsonnet" "github.com/google/go-jsonnet/formatter" @@ -34,6 +36,13 @@ var playgroundCmd = &cobra.Command{ RunE: runPlayground, } +func sendJSONResponse(w http.ResponseWriter, data interface{}) { + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(data); err != nil { + http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError) + } +} + func runPlayground(cmd *cobra.Command, args []string) error { ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -112,7 +121,128 @@ func handleDemo(w http.ResponseWriter, r *http.Request) { } func handleTest(w http.ResponseWriter, r *http.Request) { - return + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var request struct { + Config string `json:"config"` + } + + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + + combinedConfig := fmt.Sprintf(`local sub = %s; + +%s`, substation.Libsonnet, request.Config) + + vm := jsonnet.MakeVM() + jsonString, err := vm.EvaluateAnonymousSnippet("", combinedConfig) + if err != nil { + http.Error(w, fmt.Sprintf("Error evaluating Jsonnet: %v", err), http.StatusBadRequest) + return + } + + var cfg customConfig + if err := json.Unmarshal([]byte(jsonString), &cfg); err != nil { + http.Error(w, fmt.Sprintf("Invalid configuration: %v", err), http.StatusBadRequest) + return + } + + ctx := r.Context() + var output strings.Builder + + if len(cfg.Transforms) == 0 { + output.WriteString("?\t[config error]\n") + sendJSONResponse(w, map[string]string{"output": output.String()}) + return + } + + if len(cfg.Tests) == 0 { + output.WriteString("?\t[no tests]\n") + sendJSONResponse(w, map[string]string{"output": output.String()}) + return + } + + start := time.Now() + failedTests := false + + for _, test := range cfg.Tests { + cnd, err := condition.New(ctx, test.Condition) + if err != nil { + output.WriteString("?\t[test error]\n") + sendJSONResponse(w, map[string]string{"output": output.String()}) + return + } + + setup, err := substation.New(ctx, substation.Config{ + Transforms: test.Transforms, + }) + if err != nil { + output.WriteString("?\t[test error]\n") + sendJSONResponse(w, map[string]string{"output": output.String()}) + return + } + + tester, err := substation.New(ctx, cfg.Config) + if err != nil { + output.WriteString("?\t[config error]\n") + sendJSONResponse(w, map[string]string{"output": output.String()}) + return + } + + sMsgs, err := setup.Transform(ctx, message.New().AsControl()) + if err != nil { + output.WriteString("?\t[test error]\n") + sendJSONResponse(w, map[string]string{"output": output.String()}) + return + } + + tMsgs, err := tester.Transform(ctx, sMsgs...) + if err != nil { + output.WriteString("?\t[config error]\n") + sendJSONResponse(w, map[string]string{"output": output.String()}) + return + } + + testPassed := true + for _, msg := range tMsgs { + if msg.IsControl() { + continue + } + + ok, err := cnd.Condition(ctx, msg) + if err != nil { + output.WriteString("?\t[test error]\n") + sendJSONResponse(w, map[string]string{"output": output.String()}) + return + } + + if !ok { + output.WriteString(fmt.Sprintf("--- FAIL: %s\n", test.Name)) + output.WriteString(fmt.Sprintf(" message:\t%s\n", msg)) + output.WriteString(fmt.Sprintf(" condition:\t%s\n", cnd)) + testPassed = false + failedTests = true + break + } + } + + if testPassed { + output.WriteString(fmt.Sprintf("--- PASS: %s\n", test.Name)) + } + } + + if failedTests { + output.WriteString(fmt.Sprintf("FAIL\t%s\n", time.Since(start).Round(time.Microsecond))) + } else { + output.WriteString(fmt.Sprintf("ok\t%s\n", time.Since(start).Round(time.Microsecond))) + } + + sendJSONResponse(w, map[string]string{"output": output.String()}) } func handleRun(w http.ResponseWriter, r *http.Request) { @@ -172,11 +302,7 @@ func handleRun(w http.ResponseWriter, r *http.Request) { } } - if err := json.NewEncoder(w).Encode(map[string]interface{}{ - "output": output, - }); err != nil { - http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError) - } + sendJSONResponse(w, map[string]interface{}{"output": output}) } func handleFmt(w http.ResponseWriter, r *http.Request) { @@ -199,11 +325,7 @@ func handleFmt(w http.ResponseWriter, r *http.Request) { return } - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(map[string]string{"formatted": formatted}); err != nil { - http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError) - return - } + sendJSONResponse(w, map[string]string{"config": formatted}) } // Add a new handler for sharing @@ -234,8 +356,7 @@ func handleShare(w http.ResponseWriter, r *http.Request) { RawQuery: "share=" + encoded, } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"url": shareURL.String()}) + sendJSONResponse(w, map[string]string{"url": shareURL.String()}) } const indexHTML = ` @@ -605,7 +726,7 @@ const indexHTML = ` }) .then(response => response.json()) .then(data => { - configEditor.setValue(data.formatted); + configEditor.setValue(data.config); }) .catch(error => console.error('Error formatting Jsonnet:', error)); } @@ -616,12 +737,11 @@ const indexHTML = ` headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ config: configEditor.getValue(), - input: inputEditor.getValue(), }) }) .then(response => response.json()) .then(data => { - outputEditor.setValue(JSON.stringify(data, null, 2)); + outputEditor.setValue(data.output); }) .catch(error => { outputEditor.setValue('Error: ' + error); From c5ce8ff2d951b6bb7b08824a136a0c53ce6f9eaf Mon Sep 17 00:00:00 2001 From: brittonhayes Date: Tue, 22 Oct 2024 18:04:49 -0700 Subject: [PATCH 10/15] fix: disables run button while running --- cmd/substation/playground.go | 41 +++++++++++++++++++++++++++++------- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/cmd/substation/playground.go b/cmd/substation/playground.go index f2eaab04..28a4caf0 100644 --- a/cmd/substation/playground.go +++ b/cmd/substation/playground.go @@ -251,6 +251,8 @@ func handleRun(w http.ResponseWriter, r *http.Request) { return } + time.Sleep(5 * time.Second) + var request struct { Config string `json:"config"` Input string `json:"input"` @@ -573,6 +575,17 @@ const indexHTML = ` background-color: #ffffff; color: var(--text-color); } + + button:disabled { + background-color: #EDEFEE; + color: #323333; + cursor: not-allowed; + } + + button:disabled:hover { + background-color: #EDEFEE; + transform: none; + } @@ -601,7 +614,7 @@ const indexHTML = `

- + @@ -701,6 +714,12 @@ const indexHTML = ` } function runSubstation() { + const runButton = document.getElementById('runButton'); + runButton.disabled = true; + runButton.textContent = 'Running...'; + runButton.classList.remove('primary-button'); + runButton.classList.add('secondary-button'); + fetch('/run', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -709,13 +728,19 @@ const indexHTML = ` input: inputEditor.getValue(), }) }) - .then(response => response.json()) - .then(data => { - outputEditor.setValue(data.output.join('\n')); - }) - .catch(error => { - outputEditor.setValue('Error: ' + error); - }); + .then(response => response.json()) + .then(data => { + outputEditor.setValue(data.output.join('\n')); + }) + .catch(error => { + outputEditor.setValue('Error: ' + error); + }) + .finally(() => { + runButton.disabled = false; + runButton.textContent = 'Run'; + runButton.classList.remove('secondary-button'); + runButton.classList.add('primary-button'); + }); } function formatJsonnet() { From c51ff9c4269ca87dd62b6d0cd09806b0510ef2e1 Mon Sep 17 00:00:00 2001 From: brittonhayes Date: Tue, 22 Oct 2024 21:25:08 -0700 Subject: [PATCH 11/15] fix: removed artificial time.sleep statement from playground --- cmd/substation/playground.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/cmd/substation/playground.go b/cmd/substation/playground.go index 28a4caf0..9db31d25 100644 --- a/cmd/substation/playground.go +++ b/cmd/substation/playground.go @@ -251,8 +251,6 @@ func handleRun(w http.ResponseWriter, r *http.Request) { return } - time.Sleep(5 * time.Second) - var request struct { Config string `json:"config"` Input string `json:"input"` From 7961e508f59e34e78e063dcaaf91afabe6129be6 Mon Sep 17 00:00:00 2001 From: brittonhayes Date: Wed, 23 Oct 2024 09:33:41 -0700 Subject: [PATCH 12/15] fix: alignment of text/json selector with editor header --- cmd/substation/playground.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/cmd/substation/playground.go b/cmd/substation/playground.go index 9db31d25..ff09b68b 100644 --- a/cmd/substation/playground.go +++ b/cmd/substation/playground.go @@ -562,9 +562,14 @@ const indexHTML = ` display: flex; justify-content: space-between; align-items: center; + margin-top: 16px; margin-bottom: 4px; } + .editor-header h2 { + margin: 0; + } + .mode-selector { font-size: 14px; padding: 5px; From 6c82fe2040331d10e13cd69928965784fc871b21 Mon Sep 17 00:00:00 2001 From: brittonhayes Date: Thu, 24 Oct 2024 22:00:56 -0700 Subject: [PATCH 13/15] fix(playground): env var support and full error output --- cmd/substation/playground.go | 549 +++++-------------------------- cmd/substation/playground.tmpl | 572 +++++++++++++++++++++++++++++++++ 2 files changed, 648 insertions(+), 473 deletions(-) create mode 100644 cmd/substation/playground.tmpl diff --git a/cmd/substation/playground.go b/cmd/substation/playground.go index ff09b68b..b25d63f5 100644 --- a/cmd/substation/playground.go +++ b/cmd/substation/playground.go @@ -1,12 +1,14 @@ package main import ( + "bytes" "context" _ "embed" "encoding/base64" "encoding/json" "fmt" "html/template" + "io" "log" "net/http" "net/url" @@ -16,15 +18,17 @@ import ( "syscall" "time" - "github.com/spf13/cobra" - "github.com/brexhq/substation/v2" "github.com/brexhq/substation/v2/condition" "github.com/brexhq/substation/v2/message" "github.com/google/go-jsonnet" "github.com/google/go-jsonnet/formatter" + "github.com/spf13/cobra" ) +//go:embed playground.tmpl +var playgroundHTML string + func init() { rootCmd.AddCommand(playgroundCmd) } @@ -38,8 +42,24 @@ var playgroundCmd = &cobra.Command{ func sendJSONResponse(w http.ResponseWriter, data interface{}) { w.Header().Set("Content-Type", "application/json") + statusCode := http.StatusOK + + var err interface{} + switch v := data.(type) { + case map[string]interface{}: + err = v["error"] + case map[string]string: + err = v["error"] + } + + if err != nil { + statusCode = http.StatusInternalServerError + log.Printf("Error in request: %v", err) + } + + w.WriteHeader(statusCode) if err := json.NewEncoder(w).Encode(data); err != nil { - http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError) + log.Printf("Error encoding response: %v", err) } } @@ -80,10 +100,12 @@ func handleIndex(w http.ResponseWriter, r *http.Request) { DefaultConfig string DefaultInput string DefaultOutput string + DefaultEnv string }{ DefaultConfig: "", DefaultInput: "", DefaultOutput: "", + DefaultEnv: "", } // Check for shared data in query string @@ -100,7 +122,12 @@ func handleIndex(w http.ResponseWriter, r *http.Request) { } } - tmpl := template.Must(template.New("index").Parse(indexHTML)) + // If shared data is present, don't include environment variables + if sharedData == "" { + data.DefaultEnv = "# Add environment variables here, one per line\n# Example: KEY=VALUE" + } + + tmpl := template.Must(template.New("index").Parse(playgroundHTML)) if err := tmpl.Execute(w, data); err != nil { log.Printf("Error executing template: %v", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) @@ -122,7 +149,7 @@ func handleDemo(w http.ResponseWriter, r *http.Request) { func handleTest(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + sendJSONResponse(w, map[string]string{"error": "Method not allowed"}) return } @@ -131,7 +158,7 @@ func handleTest(w http.ResponseWriter, r *http.Request) { } if err := json.NewDecoder(r.Body).Decode(&request); err != nil { - http.Error(w, "Invalid request", http.StatusBadRequest) + sendJSONResponse(w, map[string]string{"error": "Invalid request"}) return } @@ -252,8 +279,9 @@ func handleRun(w http.ResponseWriter, r *http.Request) { } var request struct { - Config string `json:"config"` - Input string `json:"input"` + Config string `json:"config"` + Input string `json:"input"` + Env map[string]string `json:"env"` } if err := json.NewDecoder(r.Body).Decode(&request); err != nil { @@ -278,6 +306,11 @@ func handleRun(w http.ResponseWriter, r *http.Request) { return } + // Set up environment variables + for key, value := range request.Env { + os.Setenv(key, value) + } + sub, err := substation.New(r.Context(), cfg) if err != nil { http.Error(w, fmt.Sprintf("Error creating Substation instance: %v", err), http.StatusInternalServerError) @@ -302,12 +335,28 @@ func handleRun(w http.ResponseWriter, r *http.Request) { } } + // Clean up environment variables after processing + for key := range request.Env { + os.Unsetenv(key) + } + sendJSONResponse(w, map[string]interface{}{"output": output}) } func handleFmt(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + + log.Println("Received /fmt request") if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + log.Println("Method not allowed:", r.Method) + sendJSONResponse(w, map[string]string{"error": "Method not allowed"}) return } @@ -315,17 +364,32 @@ func handleFmt(w http.ResponseWriter, r *http.Request) { Jsonnet string `json:"jsonnet"` } if err := json.NewDecoder(r.Body).Decode(&input); err != nil { - http.Error(w, fmt.Sprintf("Error decoding request: %v", err), http.StatusBadRequest) + log.Printf("Error decoding request: %v", err) + log.Printf("Request body: %s", getRequestBody(r)) + sendJSONResponse(w, map[string]string{"error": fmt.Sprintf("Error decoding request: %v", err)}) return } + log.Printf("Received Jsonnet content: %s", input.Jsonnet) + + log.Println("Formatting Jsonnet...") formatted, err := formatter.Format("", input.Jsonnet, formatter.DefaultOptions()) if err != nil { - http.Error(w, fmt.Sprintf("Error formatting Jsonnet: %v", err), http.StatusBadRequest) + log.Printf("Error formatting Jsonnet: %v", err) + sendJSONResponse(w, map[string]string{"error": fmt.Sprintf("Error formatting Jsonnet: %v", err)}) return } - sendJSONResponse(w, map[string]string{"config": formatted}) + sendJSONResponse(w, map[string]interface{}{"config": formatted}) +} + +func getRequestBody(r *http.Request) string { + body, err := io.ReadAll(r.Body) + if err != nil { + return fmt.Sprintf("Error reading body: %v", err) + } + r.Body = io.NopCloser(bytes.NewBuffer(body)) + return string(body) } // Add a new handler for sharing @@ -358,464 +422,3 @@ func handleShare(w http.ResponseWriter, r *http.Request) { sendJSONResponse(w, map[string]string{"url": shareURL.String()}) } - -const indexHTML = ` - - - - - - - Substation | Playground - - - - - - - - - -
-
-
-
- - - - - -
-

- Run your configuration, test it, or try a demo. - View examples -

-
-
-
-
-
-
-
-

Configuration

-
-

Configure the transformations to be applied to the input event.

-
-
-
-
-
-
-

Input

- -
-

Paste the message data to be processed by Substation here.

-
-
-
-
-

Output

- -
-

The processed message data will appear here after running.

-
-
-
-
- - - - - -` diff --git a/cmd/substation/playground.tmpl b/cmd/substation/playground.tmpl new file mode 100644 index 00000000..0d78ae7a --- /dev/null +++ b/cmd/substation/playground.tmpl @@ -0,0 +1,572 @@ + + + + + + + Substation | Playground + + + + + + + + + +
+
+
+
+ + + + + +
+

+ Run your configuration, test it, or try a demo. + View examples +

+
+
+
+
+
+
+

Configuration

+ +
+

Configure the transformations to be applied to the input event.

+
+
+
+
+
+

Input

+ +
+

Paste the message data to be processed by Substation here.

+
+
+
+
+

Output

+ +
+

The processed message data will appear here after running.

+
+
+
+
+ + + + + + + + From 8d3c1116ceea5522e2406187868ee7b48defe430 Mon Sep 17 00:00:00 2001 From: brittonhayes Date: Thu, 24 Oct 2024 22:03:07 -0700 Subject: [PATCH 14/15] fix(playground): http method options --- cmd/substation/playground.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/substation/playground.go b/cmd/substation/playground.go index b25d63f5..5ea13daf 100644 --- a/cmd/substation/playground.go +++ b/cmd/substation/playground.go @@ -348,7 +348,7 @@ func handleFmt(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Content-Type") - if r.Method == "OPTIONS" { + if r.Method == http.MethodOptions { w.WriteHeader(http.StatusOK) return } From 8e099d181008fffa2ce28fa520a3fc1b0147e736 Mon Sep 17 00:00:00 2001 From: brittonhayes Date: Thu, 24 Oct 2024 22:08:18 -0700 Subject: [PATCH 15/15] fix(playground): rename libsonnet to library --- cmd/substation/playground.go | 4 ++-- substation.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd/substation/playground.go b/cmd/substation/playground.go index 5ea13daf..3c9dde96 100644 --- a/cmd/substation/playground.go +++ b/cmd/substation/playground.go @@ -164,7 +164,7 @@ func handleTest(w http.ResponseWriter, r *http.Request) { combinedConfig := fmt.Sprintf(`local sub = %s; -%s`, substation.Libsonnet, request.Config) +%s`, substation.Library, request.Config) vm := jsonnet.MakeVM() jsonString, err := vm.EvaluateAnonymousSnippet("", combinedConfig) @@ -291,7 +291,7 @@ func handleRun(w http.ResponseWriter, r *http.Request) { combinedConfig := fmt.Sprintf(`local sub = %s; -%s`, substation.Libsonnet, request.Config) +%s`, substation.Library, request.Config) vm := jsonnet.MakeVM() jsonString, err := vm.EvaluateAnonymousSnippet("", combinedConfig) diff --git a/substation.go b/substation.go index 22988bd2..39766bb7 100644 --- a/substation.go +++ b/substation.go @@ -12,7 +12,7 @@ import ( ) //go:embed substation.libsonnet -var Libsonnet string +var Library string var errNoTransforms = fmt.Errorf("no transforms configured")