From 5d40576c3e8abb6dbda66873720a76c15d01f24d Mon Sep 17 00:00:00 2001 From: brittonhayes Date: Wed, 16 Oct 2024 12:14:19 -0700 Subject: [PATCH] feat(playground): ux improvements, share button, demo, improved copy --- cmd/substation/playground.go | 417 ++++++++++++++++------------------- 1 file changed, 190 insertions(+), 227 deletions(-) diff --git a/cmd/substation/playground.go b/cmd/substation/playground.go index c9e6ce5e..3537c559 100644 --- a/cmd/substation/playground.go +++ b/cmd/substation/playground.go @@ -10,10 +10,10 @@ import ( "net/http" "os" "os/signal" + "strings" "syscall" "github.com/spf13/cobra" - "github.com/tidwall/gjson" "github.com/brexhq/substation/v2" "github.com/brexhq/substation/v2/message" @@ -39,8 +39,8 @@ func runPlayground(cmd *cobra.Command, args []string) error { mux := http.NewServeMux() mux.HandleFunc("/", handleIndex) mux.HandleFunc("/run", handleRun) + mux.HandleFunc("/demo", handleDemo) mux.HandleFunc("/fmt", handleFmt) - mux.HandleFunc("/examples", handleExamples) server := &http.Server{ Addr: ":8080", @@ -67,9 +67,10 @@ func handleIndex(w http.ResponseWriter, r *http.Request) { DefaultConfig string DefaultInput string }{ - DefaultConfig: demoConf, - DefaultInput: demoEvt, + DefaultConfig: "", + DefaultInput: "", } + tmpl := template.Must(template.New("index").Parse(indexHTML)) if err := tmpl.Execute(w, data); err != nil { log.Printf("Error executing template: %v", err) @@ -77,6 +78,19 @@ func handleIndex(w http.ResponseWriter, r *http.Request) { } } +func handleDemo(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + cleanedDemoconf := strings.ReplaceAll(demoConf, "local sub = import '../../substation.libsonnet';\n\n", "") + + if err := json.NewEncoder(w).Encode(map[string]interface{}{ + "config": cleanedDemoconf, + "input": demoEvt, + }); err != nil { + http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError) + } +} + func handleRun(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) @@ -130,7 +144,7 @@ func handleRun(w http.ResponseWriter, r *http.Request) { var output []string for _, msg := range result { if !msg.IsControl() { - output = append(output, gjson.Get(string(msg.Data()), "@this|@pretty").String()) + output = append(output, string(msg.Data())) } } @@ -141,47 +155,6 @@ func handleRun(w http.ResponseWriter, r *http.Request) { } } -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) - } -} - func handleFmt(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) @@ -232,75 +205,97 @@ const indexHTML = ` body { font-family: 'Inter', sans-serif; - max-width: 90vw; - margin: 0 auto; - padding: 40px; + margin: 0; + padding: 0; background-color: #f9f9f9; color: var(--text-color); - display: grid; - grid-template-rows: auto 1fr; - height: 100vh; + display: flex; + flex-direction: column; + min-height: 95vh; box-sizing: border-box; } - header { - margin-bottom: 40px; - display: flex; - justify-content: space-between; - align-items: flex-start; + .content-wrapper { + margin: 0 auto; + padding: 0 40px; + width: 100%; + box-sizing: border-box; } - .title { - gap: 16px; + .nav-bar { + background-color: #ffffff; + padding: 10px 0; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); } - .title-container { - position: relative; - padding-bottom: 1em; + .nav-content { + display: flex; + justify-content: space-between; + align-items: center; } - h1 { - font-size: 48px; - color: #212121; + .title { + font-size: 20px; font-weight: 800; - margin-bottom: 8px; - word-wrap: break-word; + color: #212121; } .playground-label { font-weight: 300; - font-family: 'Inter', sans-serif; color: var(--secondary-color); opacity: 0.5; } - h2 { - font-size: 24px; - color: #202020; - font-weight: 700; - margin-top: 0; - margin-bottom: 4px; + .nav-links { + display: flex; + gap: 20px; + } + + .nav-link { + color: var(--secondary-color); + text-decoration: none; + font-size: 20px; + transition: color 0.3s ease; + } + + .nav-link:hover { + color: var(--secondary-hover-color); + } + + .action-section { + padding: 20px 0; + background-color: #f0f0f04e; + border-bottom: 1px solid var(--border-color); + } + + .button-container { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 10px; } - h3 { - font-weight: 500; - color: #666666; - font-size: 18px; - margin-top: 0; + .action-row { + display: flex; + flex-direction: row; + align-items: center; + gap: 10px; } main { display: grid; grid-template-columns: 1fr 1fr; gap: 40px; - height: 100%; + flex-grow: 1; + overflow: hidden; + padding: 20px 0; } .left-column, .right-column { display: flex; flex-direction: column; - gap: 20px; + gap: 18px; overflow: hidden; } @@ -323,42 +318,17 @@ const indexHTML = ` overflow: hidden; } - .button-container { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 5px; - } - - .action-row { - display: flex; - flex-direction: row; - align-items: center; - gap: 10px; - } - - .select-container { - display: flex; - flex-direction: column; - } - - .select-container select { - height: 40px; - box-sizing: border-box; - padding: 0 10px; - } - .subtext { font-size: 12px; color: var(--secondary-color); - margin: 5px 0 8px 0; // Added bottom margin + margin: 5px 0 8px 0; } button { - padding: 0 48px; - height: 40px; + padding: 0 24px; + height: 36px; color: white; - border: none; + border: 1px solid var(--border-color); border-radius: 8px; cursor: pointer; font-family: 'Inter', sans-serif; @@ -376,91 +346,55 @@ const indexHTML = ` background-color: var(--primary-hover-color); } - .format-button { + .secondary-button { background-color: #EDEFEE; color: #323333; } - .format-button:hover { + .secondary-button:hover { background-color: #D9DBD9; } - .secondary-link { - color: var(--secondary-color); + button:active { + transform: translateY(1px); + } + + .examples-link { + color: var(--primary-color); text-decoration: none; - font-weight: 500; } - button:active { - transform: translateY(1px); + .examples-link:hover { + text-decoration: underline; } @media (max-width: 1200px) { - body { - max-width: 95vw; - } - main { grid-template-columns: 1fr; } - - h1 { - font-size: 36px; - } - - h3 { - font-size: 16px; + .content-wrapper { + padding: 0 20px; } } - - .nav-bar { - position: absolute; - top: 20px; - right: 40px; - display: flex; - gap: 20px; - } - - .nav-link { - color: var(--secondary-color); - text-decoration: none; - font-size: 24px; - transition: color 0.3s ease; + h2 { + margin: 24px 0px 0px 0px } - .nav-link:hover { - color: var(--secondary-hover-color); + .editor-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 4px; } - select { - padding: 10px; - font-size: 16px; - border: 1px solid var(--border-color); + .mode-selector { + font-size: 14px; + padding: 5px; border-radius: 4px; + border: 1px solid var(--border-color); background-color: #ffffff; color: var(--text-color); - cursor: pointer; - } - - select:hover { - border-color: var(--primary-color); - } - - .logo { - height: 36px; - width: auto; - } - - .title { - display: flex; - align-items: center; - } - - .logo-container { - position: absolute; - top: 20px; - left: 40px; } @@ -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); + }); }