From 81b00fb493311795b47ce95890e18e62ca92ff00 Mon Sep 17 00:00:00 2001 From: Jake Thomas Date: Sat, 18 Jul 2026 21:14:27 -0400 Subject: [PATCH 1/2] feat: add CEL filter autocomplete --- internal/ui/assets/README.md | 9 + internal/ui/assets/app.css | 99 +++++ internal/ui/assets/app.js | 341 ++++++++++++++++++ internal/ui/cel_autocomplete.go | 205 +++++++++++ internal/ui/forms_test.go | 27 ++ .../ui/templates/frag_connector_form.html | 17 +- internal/ui/templates/layout.html | 1 + internal/ui/ui.go | 12 +- internal/ui/ui_test.go | 1 + tests/browser/ui.spec.ts | 28 ++ 10 files changed, 732 insertions(+), 8 deletions(-) create mode 100644 internal/ui/assets/app.js create mode 100644 internal/ui/cel_autocomplete.go diff --git a/internal/ui/assets/README.md b/internal/ui/assets/README.md index 85c0e6f..a031150 100644 --- a/internal/ui/assets/README.md +++ b/internal/ui/assets/README.md @@ -22,6 +22,15 @@ Used for progressive-enhancement navigation (`hx-boost`) and form fragments. Plain links and server-rendered pages still work without it; htmx only avoids full page reloads and handles the fragment swaps. +## app.js + +Hand-authored progressive enhancement for the connector form's CEL filter +textarea. It supplies accessible, keyboard-driven typeahead for envelope +fields, CEL helpers, PGN numbers, and decoded payload keys. The schema-backed +items are fetched from `/ui/cel-completions`; a small built-in fallback keeps +the core interaction useful if that request fails. No configuration write +depends on this script, and filters are still validated server-side. + ## app.css `app.css` is hand-authored source, not a compiled Tailwind/daisyUI artifact. diff --git a/internal/ui/assets/app.css b/internal/ui/assets/app.css index 0e5a14f..8d72422 100644 --- a/internal/ui/assets/app.css +++ b/internal/ui/assets/app.css @@ -892,6 +892,105 @@ code { resize: vertical; } +.cel-autocomplete { + position: relative; +} + +.cel-filter-input { + display: block; + font-family: var(--font-mono); + line-height: 1.45; +} + +.cel-completions { + position: absolute; + z-index: 40; + top: calc(100% + 4px); + right: 0; + left: 0; + max-height: 19rem; + overflow-y: auto; + border: 1px solid var(--border-strong); + border-radius: 6px; + background: var(--surface); + box-shadow: var(--shadow); + padding: 4px; +} + +.cel-completion-option { + display: grid; + gap: 2px; + border-radius: 4px; + padding: 0.48rem 0.58rem; + cursor: pointer; +} + +.cel-completion-option[aria-selected="true"] { + background: var(--surface-strong); +} + +.cel-completion-main { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--space-3); + min-width: 0; +} + +.cel-completion-label { + overflow: hidden; + background: transparent; + padding: 0; + text-overflow: ellipsis; + white-space: nowrap; +} + +.cel-completion-kind { + flex: none; + color: var(--gray); + font-size: 0.7rem; + font-weight: 800; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.cel-completion-detail, +.field-help { + color: var(--gray); + font-size: 0.78rem; +} + +.cel-completion-detail { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.field-help { + margin: 0.38rem 0 0; +} + +.field-help kbd { + border: 1px solid var(--border-strong); + border-bottom-width: 2px; + border-radius: 4px; + background: var(--surface-muted); + color: var(--gray-dark); + font-family: var(--font-mono); + font-size: 0.72em; + padding: 0.08rem 0.28rem; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + clip-path: inset(50%); + white-space: nowrap; +} + .input:focus, .select:focus, .textarea:focus { diff --git a/internal/ui/assets/app.js b/internal/ui/assets/app.js new file mode 100644 index 0000000..e846d73 --- /dev/null +++ b/internal/ui/assets/app.js @@ -0,0 +1,341 @@ +(function () { + "use strict"; + + var maxResults = 9; + var catalog = { + payloadFields: [ + { label: "msg.payload.heading", detail: "Heading · PGN 127250" }, + { label: "msg.payload.speedWaterReferenced", detail: "Speed Water Referenced · PGN 128259" } + ], + pgns: [ + { label: "127250", detail: "Vessel Heading" }, + { label: "128259", detail: "Speed" }, + { label: "129025", detail: "Position, Rapid Update" }, + { label: "129026", detail: "COG & SOG, Rapid Update" }, + { label: "129029", detail: "GNSS Position Data" } + ] + }; + var catalogPromise; + + var envelopeItems = [ + completion("msg.pgn", "int · NMEA 2000 PGN number", "field"), + completion("msg.source", "int · source device address", "field"), + completion("msg.dest", "int · destination address", "field"), + completion("msg.priority", "int · 0 (highest) to 7 (lowest)", "field"), + completion("msg.timestamp", "string · RFC 3339 timestamp", "field"), + completion("msg.payload", "map · decoded PGN fields", "field") + ]; + + var languageItems = [ + completion("has()", "macro · test whether an optional field exists", "function", "has()", -1), + completion("size()", "function · string, list, or map size", "function", "size()", -1), + completion("int()", "function · convert a value to int", "function", "int()", -1), + completion("double()", "function · convert a value to double", "function", "double()", -1), + completion("string()", "function · convert a value to string", "function", "string()", -1), + completion("true", "bool literal", "keyword"), + completion("false", "bool literal", "keyword"), + completion("in", "membership operator", "keyword", "in ") + ]; + + function completion(label, detail, kind, insertText, cursorOffset) { + return { + label: label, + detail: detail, + kind: kind, + insertText: insertText || label, + cursorOffset: cursorOffset || 0 + }; + } + + function loadCatalog() { + if (catalogPromise) return catalogPromise; + catalogPromise = fetch("/ui/cel-completions", { + headers: { Accept: "application/json" } + }) + .then(function (response) { + if (!response.ok) throw new Error("completion catalog unavailable"); + return response.json(); + }) + .then(function (data) { + catalog.payloadFields = mergeCatalogItems(catalog.payloadFields, data.payloadFields); + catalog.pgns = mergeCatalogItems(catalog.pgns, data.pgns); + return catalog; + }) + .catch(function () { + return catalog; + }); + return catalogPromise; + } + + function mergeCatalogItems(fallback, incoming) { + var merged = []; + var seen = Object.create(null); + (Array.isArray(incoming) ? incoming : fallback).forEach(function (item) { + if (!item || !item.label || seen[item.label]) return; + seen[item.label] = true; + merged.push(item); + }); + fallback.forEach(function (item) { + if (seen[item.label]) return; + seen[item.label] = true; + merged.push(item); + }); + return merged; + } + + function initCELAutocomplete(root) { + var scope = root && root.querySelectorAll ? root : document; + scope.querySelectorAll("[data-cel-autocomplete]").forEach(function (editor) { + if (editor.dataset.autocompleteReady === "true") return; + editor.dataset.autocompleteReady = "true"; + attachAutocomplete(editor); + }); + } + + function attachAutocomplete(editor) { + var textarea = editor.querySelector("textarea"); + var listbox = editor.querySelector("[role=listbox]"); + var status = editor.querySelector("[data-cel-completion-status]"); + if (!textarea || !listbox || !status) return; + + var matches = []; + var activeIndex = -1; + var replaceStart = 0; + var replaceEnd = 0; + + function close() { + matches = []; + activeIndex = -1; + listbox.hidden = true; + listbox.replaceChildren(); + textarea.setAttribute("aria-expanded", "false"); + textarea.removeAttribute("aria-activedescendant"); + status.textContent = ""; + } + + function setActive(index) { + if (!matches.length) return; + activeIndex = (index + matches.length) % matches.length; + Array.prototype.forEach.call(listbox.children, function (option, optionIndex) { + option.setAttribute("aria-selected", optionIndex === activeIndex ? "true" : "false"); + }); + var active = listbox.children[activeIndex]; + if (active) { + textarea.setAttribute("aria-activedescendant", active.id); + active.scrollIntoView({ block: "nearest" }); + } + } + + function render(context) { + matches = context.items.slice(0, context.limit || maxResults); + replaceStart = context.start; + replaceEnd = context.end; + listbox.replaceChildren(); + if (!matches.length) { + close(); + return; + } + + matches.forEach(function (item, index) { + var option = document.createElement("div"); + option.id = listbox.id + "-option-" + index; + option.className = "cel-completion-option"; + option.setAttribute("role", "option"); + option.setAttribute("aria-selected", "false"); + option.dataset.index = String(index); + + var main = document.createElement("span"); + main.className = "cel-completion-main"; + var label = document.createElement("code"); + label.className = "cel-completion-label"; + label.textContent = item.label; + var kind = document.createElement("span"); + kind.className = "cel-completion-kind"; + kind.textContent = item.kind; + main.append(label, kind); + + var detail = document.createElement("span"); + detail.className = "cel-completion-detail"; + detail.textContent = item.detail; + option.append(main, detail); + + option.addEventListener("pointermove", function () { + setActive(index); + }); + option.addEventListener("pointerdown", function (event) { + event.preventDefault(); + accept(index); + }); + listbox.appendChild(option); + }); + + listbox.hidden = false; + textarea.setAttribute("aria-expanded", "true"); + status.textContent = matches.length + " CEL completions available. Use arrow keys and Enter to choose."; + setActive(0); + } + + function update(manual) { + var context = completionContext(textarea.value, textarea.selectionStart, manual); + if (!context || !context.items.length) { + close(); + return; + } + render(context); + } + + function accept(index) { + var item = matches[index]; + if (!item) return; + var inserted = item.insertText || item.label; + textarea.value = textarea.value.slice(0, replaceStart) + inserted + textarea.value.slice(replaceEnd); + var caret = replaceStart + inserted.length + (item.cursorOffset || 0); + textarea.setSelectionRange(caret, caret); + textarea.dispatchEvent(new Event("input", { bubbles: true })); + close(); + textarea.focus(); + } + + textarea.addEventListener("focus", function () { + loadCatalog().then(function () { + if (document.activeElement === textarea) update(false); + }); + }); + textarea.addEventListener("input", function () { + update(false); + }); + textarea.addEventListener("click", function () { + update(false); + }); + textarea.addEventListener("keyup", function (event) { + if (["ArrowLeft", "ArrowRight", "Home", "End"].indexOf(event.key) !== -1) update(false); + }); + textarea.addEventListener("keydown", function (event) { + if (event.ctrlKey && !event.altKey && !event.metaKey && event.code === "Space") { + event.preventDefault(); + update(true); + return; + } + if (listbox.hidden) return; + if (event.key === "ArrowDown") { + event.preventDefault(); + setActive(activeIndex + 1); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + setActive(activeIndex - 1); + } else if (event.key === "Enter" || event.key === "Tab") { + event.preventDefault(); + accept(activeIndex); + } else if (event.key === "Escape") { + event.preventDefault(); + close(); + } + }); + textarea.addEventListener("blur", function () { + window.setTimeout(close, 100); + }); + document.addEventListener("pointerdown", function (event) { + if (!editor.contains(event.target)) close(); + }); + + loadCatalog(); + } + + function completionContext(value, cursor, manual) { + var lineStart = value.lastIndexOf("\n", cursor - 1) + 1; + var before = value.slice(lineStart, cursor); + var identifierSuffix = value.slice(cursor).match(/^[A-Za-z0-9_.]*/)[0]; + var numericSuffix = value.slice(cursor).match(/^\d*/)[0]; + + var pgnMatch = before.match(/(?:msg\.pgn\s*(?:==|!=|<=|>=|<|>)\s*|msg\.pgn\s+in\s+\[[^\]]*?)(\d*)$/i); + if (pgnMatch) { + var digits = pgnMatch[1]; + return { + start: cursor - digits.length, + end: cursor + numericSuffix.length, + items: rankedItems(catalog.pgns.map(catalogCompletion("pgn")), digits, numericLabel) + }; + } + + var tokenMatch = before.match(/[A-Za-z_][A-Za-z0-9_.]*$/); + var token = tokenMatch ? tokenMatch[0] : ""; + var start = cursor - token.length; + var payloadPrefix = "msg.payload."; + if (token.toLowerCase().indexOf(payloadPrefix) === 0) { + var payloadQuery = token.slice(payloadPrefix.length); + return { + start: start, + end: cursor + identifierSuffix.length, + items: rankedItems(catalog.payloadFields.map(catalogCompletion("payload")), payloadQuery, tailLabel) + }; + } + + if (!token && !manual) return null; + return { + start: start, + end: cursor + identifierSuffix.length, + items: rankedItems(envelopeItems.concat(languageItems), token, genericLabel), + limit: manual && !token ? envelopeItems.length + languageItems.length : maxResults + }; + } + + function catalogCompletion(kind) { + return function (item) { + return completion(item.label, item.detail, kind); + }; + } + + function numericLabel(item) { + return item.label; + } + + function tailLabel(item) { + var parts = item.label.split("."); + return parts[parts.length - 1]; + } + + function genericLabel(item) { + var label = item.label.replace(/\(\)$/, ""); + var parts = label.split("."); + return label + " " + parts[parts.length - 1]; + } + + function rankedItems(items, query, searchable) { + var normalized = query.toLowerCase(); + return items + .map(function (item, index) { + var text = searchable(item).toLowerCase(); + var score = normalized === "" ? 0 : matchScore(text, normalized); + return { item: item, score: score, index: index }; + }) + .filter(function (entry) { + return entry.score < 100; + }) + .sort(function (left, right) { + return left.score - right.score || left.index - right.index; + }) + .map(function (entry) { + return entry.item; + }); + } + + function matchScore(text, query) { + if (text === query) return 0; + if (text.indexOf(query) === 0) return 1; + var words = text.split(/[.\s]/); + if (words.some(function (word) { return word.indexOf(query) === 0; })) return 2; + if (text.indexOf(query) !== -1) return 3; + return 100; + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", function () { + initCELAutocomplete(document); + }); + } else { + initCELAutocomplete(document); + } + document.addEventListener("htmx:load", function (event) { + initCELAutocomplete(event.detail && event.detail.elt ? event.detail.elt : document); + }); +})(); diff --git a/internal/ui/cel_autocomplete.go b/internal/ui/cel_autocomplete.go new file mode 100644 index 0000000..744b40c --- /dev/null +++ b/internal/ui/cel_autocomplete.go @@ -0,0 +1,205 @@ +package ui + +import ( + "encoding/json" + "net/http" + "sort" + "strconv" + "strings" + "sync" + + "github.com/open-ships/n2k/pgn" +) + +// celCompletionCatalog is the schema-backed portion of the connector form's +// CEL completion data. Envelope fields and CEL functions live in app.js; PGN +// numbers and decoded payload keys come from the same n2k metadata used by the +// decoder so UI suggestions cannot silently drift from the messages Beacon +// actually produces. +type celCompletionCatalog struct { + PayloadFields []celCompletionItem `json:"payloadFields"` + PGNs []celCompletionItem `json:"pgns"` +} + +type celCompletionItem struct { + Label string `json:"label"` + Detail string `json:"detail"` +} + +type payloadFieldSummary struct { + names map[string]struct{} + pgns map[uint32]struct{} +} + +var ( + celCatalogOnce sync.Once + celCatalog celCompletionCatalog +) + +// handleCELCompletions serves immutable-for-this-process metadata as JSON. +// The response is deliberately revalidated rather than cached immutably: its +// URL is not asset-versioned, and a browser may remain open across a Beacon +// binary upgrade that brings a newer n2k schema. +func handleCELCompletions(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("Cache-Control", "no-cache") + _ = json.NewEncoder(w).Encode(getCELCompletionCatalog()) +} + +func getCELCompletionCatalog() celCompletionCatalog { + celCatalogOnce.Do(func() { + celCatalog = buildCELCompletionCatalog() + }) + return celCatalog +} + +func buildCELCompletionCatalog() celCompletionCatalog { + fieldSummaries := make(map[string]*payloadFieldSummary) + pgnDescriptions := make(map[uint32]map[string]struct{}) + + for pgnNumber, variants := range pgn.PgnInfoLookup { + for _, info := range variants { + if info == nil { + continue + } + if _, ok := pgnDescriptions[pgnNumber]; !ok { + pgnDescriptions[pgnNumber] = make(map[string]struct{}) + } + if info.Description != "" { + pgnDescriptions[pgnNumber][info.Description] = struct{}{} + } + + for order, field := range info.Fields { + if field == nil || repeatingField(info, order) || !celIdentifier(field.SourceID) { + continue + } + if field.SourceType == "RESERVED" || field.SourceType == "SPARE" || field.SourceID == "reserved" { + continue + } + addPayloadField(fieldSummaries, field.SourceID, field.Name, pgnNumber) + } + + // Repeating field-set members are nested below these actual JSON + // payload keys, not flattened at the payload root. + if info.RepeatingFieldSet1StartField != nil { + addPayloadField(fieldSummaries, "repeating1", "Repeating field set", pgnNumber) + } + if info.RepeatingFieldSet2StartField != nil { + addPayloadField(fieldSummaries, "repeating2", "Repeating field set", pgnNumber) + } + } + } + + catalog := celCompletionCatalog{ + PayloadFields: make([]celCompletionItem, 0, len(fieldSummaries)), + PGNs: make([]celCompletionItem, 0, len(pgnDescriptions)), + } + for field, summary := range fieldSummaries { + catalog.PayloadFields = append(catalog.PayloadFields, celCompletionItem{ + Label: "msg.payload." + field, + Detail: payloadFieldDetail(summary), + }) + } + for pgnNumber, descriptions := range pgnDescriptions { + catalog.PGNs = append(catalog.PGNs, celCompletionItem{ + Label: strconv.FormatUint(uint64(pgnNumber), 10), + Detail: pgnDetail(descriptions), + }) + } + + sort.Slice(catalog.PayloadFields, func(i, j int) bool { + return strings.ToLower(catalog.PayloadFields[i].Label) < strings.ToLower(catalog.PayloadFields[j].Label) + }) + sort.Slice(catalog.PGNs, func(i, j int) bool { + left, _ := strconv.ParseUint(catalog.PGNs[i].Label, 10, 32) + right, _ := strconv.ParseUint(catalog.PGNs[j].Label, 10, 32) + return left < right + }) + return catalog +} + +func addPayloadField(fields map[string]*payloadFieldSummary, id, name string, pgnNumber uint32) { + summary, ok := fields[id] + if !ok { + summary = &payloadFieldSummary{names: make(map[string]struct{}), pgns: make(map[uint32]struct{})} + fields[id] = summary + } + if name != "" { + summary.names[name] = struct{}{} + } + summary.pgns[pgnNumber] = struct{}{} +} + +func repeatingField(info *pgn.PgnInfo, order int) bool { + starts := []*int{info.RepeatingFieldSet1StartField, info.RepeatingFieldSet2StartField} + for _, start := range starts { + if start != nil && order >= *start { + return true + } + } + return false +} + +func celIdentifier(s string) bool { + if s == "" || !asciiLetterOrUnderscore(s[0]) { + return false + } + for i := 1; i < len(s); i++ { + if !asciiLetterOrUnderscore(s[i]) && (s[i] < '0' || s[i] > '9') { + return false + } + } + return true +} + +func asciiLetterOrUnderscore(b byte) bool { + return b == '_' || (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') +} + +func payloadFieldDetail(summary *payloadFieldSummary) string { + names := sortedStrings(summary.names) + detail := "payload field" + if len(names) > 0 { + detail = names[0] + } + return detail + " · " + summarizePGNs(summary.pgns) +} + +func pgnDetail(descriptions map[string]struct{}) string { + items := sortedStrings(descriptions) + if len(items) == 0 { + return "NMEA 2000 PGN" + } + if len(items) == 1 { + return items[0] + } + return items[0] + " · " + strconv.Itoa(len(items)) + " variants" +} + +func summarizePGNs(values map[uint32]struct{}) string { + pgns := make([]uint32, 0, len(values)) + for value := range values { + pgns = append(pgns, value) + } + sort.Slice(pgns, func(i, j int) bool { return pgns[i] < pgns[j] }) + if len(pgns) == 1 { + return "PGN " + strconv.FormatUint(uint64(pgns[0]), 10) + } + if len(pgns) <= 3 { + labels := make([]string, len(pgns)) + for i, value := range pgns { + labels[i] = strconv.FormatUint(uint64(value), 10) + } + return "PGNs " + strings.Join(labels, ", ") + } + return strconv.Itoa(len(pgns)) + " PGNs" +} + +func sortedStrings(values map[string]struct{}) []string { + items := make([]string, 0, len(values)) + for value := range values { + items = append(items, value) + } + sort.Strings(items) + return items +} diff --git a/internal/ui/forms_test.go b/internal/ui/forms_test.go index 3cc06df..0fb41c8 100644 --- a/internal/ui/forms_test.go +++ b/internal/ui/forms_test.go @@ -1111,6 +1111,11 @@ func TestConnectorNewPageOpensCreateForm(t *testing.T) { `name="id"`, `value="src1"`, `value="sink1"`, + `data-cel-autocomplete`, + `role="combobox"`, + `aria-autocomplete="list"`, + `role="listbox"`, + `aria-keyshortcuts="Control+Space"`, `aria-label="Breadcrumb"`, `href="/ui/connectors"`, `aria-current="page">Add connector`, @@ -1121,6 +1126,28 @@ func TestConnectorNewPageOpensCreateForm(t *testing.T) { } } +func TestCELCompletionCatalogIncludesSchemaFieldsAndPGNs(t *testing.T) { + srv, _ := newUIServerWithService(t) + + resp, err := http.Get(srv.URL + "/ui/cel-completions") + if err != nil { + t.Fatal(err) + } + mustStatus(t, resp, http.StatusOK) + if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "application/json") { + t.Fatalf("Content-Type = %q, want application/json", ct) + } + body := mustBody(t, resp) + for _, want := range []string{`"payloadFields"`, `"label":"msg.payload.heading"`, `"label":"msg.payload.speedWaterReferenced"`, `"pgns"`, `"label":"127250"`, "Vessel Heading"} { + if !strings.Contains(body, want) { + t.Fatalf("CEL completion catalog missing %q:\n%s", want, body) + } + } + if strings.Contains(body, `"label":"msg.payload.reserved"`) { + t.Fatalf("CEL completion catalog should omit reserved payload fields:\n%s", body) + } +} + func TestConnectorEditPageOpensEditForm(t *testing.T) { srv, svc := newUIServerWithService(t) seedSourceSink(t, svc) diff --git a/internal/ui/templates/frag_connector_form.html b/internal/ui/templates/frag_connector_form.html index 4b416c9..44f7611 100644 --- a/internal/ui/templates/frag_connector_form.html +++ b/internal/ui/templates/frag_connector_form.html @@ -57,9 +57,20 @@

{{if .IsEdit}}Edit connector {{.ID}}{{else}}Add connector
Filters (one CEL expression per line) - +
+ + + +
+

Start with msg. or press Ctrl+Space. Use arrow keys and Enter to complete.

diff --git a/internal/ui/templates/layout.html b/internal/ui/templates/layout.html index 3852e7b..6ee2ef4 100644 --- a/internal/ui/templates/layout.html +++ b/internal/ui/templates/layout.html @@ -7,6 +7,7 @@ +
diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 6a92162..86f7686 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -1,9 +1,10 @@ // Package ui is beacon's offline, server-rendered web UI mounted at /ui/. -// Every asset it serves (htmx and the lightweight Open Ships stylesheet) is -// embedded in the binary via go:embed — beacon is an offline gateway -// appliance, so the UI must render with no network access beyond the -// browser talking to beacon itself. See assets/README.md for exactly what's -// vendored and internal/ui/assets/app.css for the theme source. +// Every asset it serves (htmx, the CEL autocomplete enhancement, and the +// lightweight Open Ships stylesheet) is embedded in the binary via go:embed — +// beacon is an offline gateway appliance, so the UI must render with no +// network access beyond the browser talking to beacon itself. See +// assets/README.md for the asset inventory and internal/ui/assets/app.css for +// the theme source. package ui import ( @@ -128,6 +129,7 @@ func Handler(svc *config.Service, reg *stats.Registry, statuses func() []supervi mux.HandleFunc("GET /ui/connectors/{id}/edit", handleConnectorEditPage(svc, reg, assetVersion, log)) mux.HandleFunc("GET /ui/connectors/{id}", redirectToTrailingSlash) mux.HandleFunc("GET /ui/connectors/{id}/{$}", handleConnectorOverviewPage(svc, reg, statuses, assetVersion, log)) + mux.HandleFunc("GET /ui/cel-completions", handleCELCompletions) mux.HandleFunc("POST /ui/frag/validate-filters", handleValidateFiltersFrag(svc, log)) mux.HandleFunc("GET /ui/frag/connectors/{id}/stats", handleConnectorStatsFrag(svc, reg, log)) mux.HandleFunc("GET /ui/frag/connectors/{id}/overview", handleConnectorOverviewFrag(svc, reg, statuses, log)) diff --git a/internal/ui/ui_test.go b/internal/ui/ui_test.go index cf92c27..e388d22 100644 --- a/internal/ui/ui_test.go +++ b/internal/ui/ui_test.go @@ -300,6 +300,7 @@ func TestAssetsServed(t *testing.T) { minSize int // 0 means "just check >0 bytes" }{ {"htmx.min.js", "javascript", 0}, + {"app.js", "javascript", 1024}, {"app.css", "css", 1024}, {"favicon.svg", "image/svg", 1024}, } diff --git a/tests/browser/ui.spec.ts b/tests/browser/ui.spec.ts index 1802395..ad95f06 100644 --- a/tests/browser/ui.spec.ts +++ b/tests/browser/ui.spec.ts @@ -25,3 +25,31 @@ test('operator can load the dashboard and create a source', async ({ page }) => await expect(page.getByRole('status')).toContainText(`Source "${sourceID}" created`); await expect(page.getByRole('link', { name: sourceName })).toBeVisible(); }); + +test('connector CEL filters provide typeahead and keyboard completion', async ({ page }) => { + await page.goto('/ui/connectors/new'); + + const filters = page.locator('#conn-filters'); + const completions = page.getByRole('listbox', { name: 'CEL completions' }); + + await filters.fill('msg.p'); + await expect(completions).toBeVisible(); + await expect(page.getByRole('option', { name: /msg\.pgn/ })).toBeVisible(); + await filters.press('Enter'); + await expect(filters).toHaveValue('msg.pgn'); + + await filters.fill('msg.pgn == 12725'); + await expect(page.getByRole('option', { name: /127250.*Vessel Heading/ })).toBeVisible(); + await filters.press('Enter'); + await expect(filters).toHaveValue('msg.pgn == 127250'); + + await filters.fill('msg.payload.speedW'); + await expect(page.getByRole('option', { name: /msg\.payload\.speedWaterReferenced\s/ })).toBeVisible(); + await filters.press('Enter'); + await expect(filters).toHaveValue('msg.payload.speedWaterReferenced'); + + await filters.fill(''); + await filters.press('Control+Space'); + await expect(completions).toBeVisible(); + await expect(page.getByRole('option', { name: /has\(\).*optional field/ })).toBeVisible(); +}); From 600c78960171739bcb67bed1a0a31408c5fb6902 Mon Sep 17 00:00:00 2001 From: Jake Thomas Date: Sat, 18 Jul 2026 21:41:44 -0400 Subject: [PATCH 2/2] feat: validate CEL filters as you type --- internal/filter/diagnostics.go | 162 ++++++++++++++++ internal/filter/filter.go | 6 +- internal/filter/filter_test.go | 24 +++ internal/ui/assets/README.md | 6 +- internal/ui/assets/app.css | 75 +++++++- internal/ui/assets/app.js | 175 ++++++++++++++++++ internal/ui/cel_validation.go | 134 ++++++++++++++ internal/ui/docs/04-filters.md | 7 +- internal/ui/forms.go | 25 ++- internal/ui/forms_test.go | 45 ++++- .../ui/templates/frag_connector_form.html | 20 +- .../ui/templates/frag_filter_validate.html | 11 +- internal/ui/ui.go | 2 +- tests/browser/ui.spec.ts | 17 +- 14 files changed, 675 insertions(+), 34 deletions(-) create mode 100644 internal/filter/diagnostics.go create mode 100644 internal/ui/cel_validation.go diff --git a/internal/filter/diagnostics.go b/internal/filter/diagnostics.go new file mode 100644 index 0000000..579d1bd --- /dev/null +++ b/internal/filter/diagnostics.go @@ -0,0 +1,162 @@ +package filter + +import ( + "strings" + "unicode/utf8" +) + +// Diagnostic is one CEL compile error tied to a byte range in one expression. +// Line is 1-based; Column and EndColumn are 0-based byte columns. CEL reports +// an error point rather than a span, so Diagnose expands that point to the +// smallest surrounding token that can usefully be highlighted by an editor. +type Diagnostic struct { + Expression int + Line int + Column int + EndColumn int + Message string +} + +// Diagnose compiles every expression and returns every parse/type-check error +// instead of stopping at the first invalid filter. An error is reserved for an +// environment construction failure; invalid CEL is represented by diagnostics. +func Diagnose(exprs []string) ([]Diagnostic, error) { + env, err := newEnvironment() + if err != nil { + return nil, err + } + + var diagnostics []Diagnostic + seen := make(map[[4]int]struct{}) + for expressionIndex, expr := range exprs { + _, issues := env.Compile(expr) + for _, issue := range issues.Errors() { + line := issue.Location.Line() + column := issue.Location.Column() + if line < 1 { + line = 1 + } + if column < 0 { + column = 0 + } + start, end := diagnosticTokenRange(expr, line, column) + key := [4]int{expressionIndex, line, start, end} + if _, duplicate := seen[key]; duplicate { + continue + } + seen[key] = struct{}{} + diagnostics = append(diagnostics, Diagnostic{ + Expression: expressionIndex, + Line: line, + Column: start, + EndColumn: end, + Message: issue.Message, + }) + } + } + return diagnostics, nil +} + +func diagnosticTokenRange(expr string, line, column int) (int, int) { + lines := strings.Split(expr, "\n") + if line < 1 || line > len(lines) { + return 0, firstRuneEnd(expr, 0) + } + text := lines[line-1] + if text == "" { + return 0, 0 + } + if column > len(text) { + column = len(text) + } + + // EOF diagnostics have no character at their reported point. Highlight + // the preceding token (usually a dangling operator) instead. + if column == len(text) { + end := column + for end > 0 && asciiSpace(text[end-1]) { + end-- + } + if end == 0 { + return 0, firstRuneEnd(text, 0) + } + start := previousTokenStart(text, end) + return start, end + } + + start := column + for start < len(text) && asciiSpace(text[start]) { + start++ + } + if start == len(text) { + end := start + for end > 0 && asciiSpace(text[end-1]) { + end-- + } + return previousTokenStart(text, end), end + } + return start, nextTokenEnd(text, start) +} + +func previousTokenStart(text string, end int) int { + start := end - 1 + kind := tokenKind(text[start]) + if kind == tokenOther { + _, size := utf8.DecodeLastRuneInString(text[:end]) + return end - size + } + for start > 0 && tokenKind(text[start-1]) == kind { + start-- + } + return start +} + +func nextTokenEnd(text string, start int) int { + kind := tokenKind(text[start]) + if kind == tokenOther { + return firstRuneEnd(text, start) + } + end := start + 1 + for end < len(text) && (tokenKind(text[end]) == kind || kind == tokenWord && asciiDigit(text[end])) { + end++ + } + return end +} + +func firstRuneEnd(text string, start int) int { + if start >= len(text) { + return start + } + _, size := utf8.DecodeRuneInString(text[start:]) + return start + size +} + +type diagnosticTokenKind uint8 + +const ( + tokenOther diagnosticTokenKind = iota + tokenWord + tokenNumber + tokenOperator +) + +func tokenKind(b byte) diagnosticTokenKind { + switch { + case b == '_' || (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z'): + return tokenWord + case (b >= '0' && b <= '9') || b == '.': + return tokenNumber + case strings.ContainsRune("=!<>&|+-*/%?", rune(b)): + return tokenOperator + default: + return tokenOther + } +} + +func asciiSpace(b byte) bool { + return b == ' ' || b == '\t' || b == '\r' +} + +func asciiDigit(b byte) bool { + return b >= '0' && b <= '9' +} diff --git a/internal/filter/filter.go b/internal/filter/filter.go index bf67bea..7e2e50c 100644 --- a/internal/filter/filter.go +++ b/internal/filter/filter.go @@ -19,7 +19,7 @@ type Chain struct { } func Compile(exprs []string) (*Chain, error) { - env, err := cel.NewEnv(cel.Variable("msg", cel.MapType(cel.StringType, cel.DynType))) + env, err := newEnvironment() if err != nil { return nil, err } @@ -38,6 +38,10 @@ func Compile(exprs []string) (*Chain, error) { return c, nil } +func newEnvironment() (*cel.Env, error) { + return cel.NewEnv(cel.Variable("msg", cel.MapType(cel.StringType, cel.DynType))) +} + // Match evaluates all expressions (AND). Returns an error if any // expression errors at eval time; callers drop the message and count it. func (c *Chain) Match(e *msg.Envelope) (bool, error) { diff --git a/internal/filter/filter_test.go b/internal/filter/filter_test.go index 249dd9c..d2d0165 100644 --- a/internal/filter/filter_test.go +++ b/internal/filter/filter_test.go @@ -80,3 +80,27 @@ func TestEmptyChainMatchesAll(t *testing.T) { t.Fatal("empty chain must pass everything") } } + +func TestDiagnoseReturnsOffendingTokenRanges(t *testing.T) { + exprs := []string{"msg.pgn == 127250", "msg.source == @", "msg.priority ==", "unknown2 == true"} + diagnostics, err := Diagnose(exprs) + if err != nil { + t.Fatal(err) + } + if len(diagnostics) != 3 { + t.Fatalf("diagnostics = %+v, want three errors", diagnostics) + } + + at := diagnostics[0] + if at.Expression != 1 || at.Line != 1 || exprs[1][at.Column:at.EndColumn] != "@" { + t.Fatalf("unexpected-character diagnostic = %+v, range %q", at, exprs[1][at.Column:at.EndColumn]) + } + dangling := diagnostics[1] + if dangling.Expression != 2 || exprs[2][dangling.Column:dangling.EndColumn] != "==" { + t.Fatalf("EOF diagnostic = %+v, range %q; want dangling operator", dangling, exprs[2][dangling.Column:dangling.EndColumn]) + } + unknown := diagnostics[2] + if unknown.Expression != 3 || exprs[3][unknown.Column:unknown.EndColumn] != "unknown2" { + t.Fatalf("unknown-identifier diagnostic = %+v, range %q", unknown, exprs[3][unknown.Column:unknown.EndColumn]) + } +} diff --git a/internal/ui/assets/README.md b/internal/ui/assets/README.md index a031150..6b06d3a 100644 --- a/internal/ui/assets/README.md +++ b/internal/ui/assets/README.md @@ -28,8 +28,10 @@ Hand-authored progressive enhancement for the connector form's CEL filter textarea. It supplies accessible, keyboard-driven typeahead for envelope fields, CEL helpers, PGN numbers, and decoded payload keys. The schema-backed items are fetched from `/ui/cel-completions`; a small built-in fallback keeps -the core interaction useful if that request fails. No configuration write -depends on this script, and filters are still validated server-side. +the core interaction useful if that request fails. It also debounces CEL +compilation while the operator types and overlays compiler error ranges as red +wavy underlines. No configuration write depends on this script, and filters +are still validated server-side on Save. ## app.css diff --git a/internal/ui/assets/app.css b/internal/ui/assets/app.css index 8d72422..5554b52 100644 --- a/internal/ui/assets/app.css +++ b/internal/ui/assets/app.css @@ -896,10 +896,60 @@ code { position: relative; } +.cel-filter-editor { + position: relative; + border-radius: 6px; + background: var(--surface); +} + +.cel-filter-highlight, .cel-filter-input { - display: block; font-family: var(--font-mono); line-height: 1.45; + tab-size: 8; + white-space: pre-wrap; + overflow-wrap: break-word; +} + +.cel-filter-highlight { + position: absolute; + z-index: 2; + inset: 0; + overflow: hidden; + border: 1px solid transparent; + border-radius: 6px; + margin: 0; + color: transparent; + pointer-events: none; + padding: 0.55rem 0.7rem; +} + +.cel-filter-input { + position: relative; + z-index: 1; + display: block; + overflow: auto; + background: transparent; + caret-color: var(--gray-dark); + scrollbar-gutter: stable; +} + +.cel-filter-error { + background: transparent; + color: transparent; + text-decoration-line: underline; + text-decoration-style: wavy; + text-decoration-color: var(--error); + text-decoration-thickness: 1.5px; + text-underline-offset: 3px; +} + +.cel-filter-input[aria-invalid="true"] { + border-color: var(--error); +} + +.cel-filter-input[aria-invalid="true"]:focus { + outline-color: rgba(180, 35, 24, 0.14); } .cel-completions { @@ -970,6 +1020,29 @@ code { margin: 0.38rem 0 0; } +.filter-validation-feedback:not(:empty) { + margin-top: 0.45rem; +} + +.filter-validation-checking { + color: var(--gray); + font-size: 0.82rem; +} + +.filter-validation-errors { + padding: 0.65rem 0.78rem; + font-size: 0.82rem; +} + +.filter-validation-errors ul { + margin: 0; + padding-left: 1.2rem; +} + +.filter-validation-errors li + li { + margin-top: 0.25rem; +} + .field-help kbd { border: 1px solid var(--border-strong); border-bottom-width: 2px; diff --git a/internal/ui/assets/app.js b/internal/ui/assets/app.js index e846d73..1b26180 100644 --- a/internal/ui/assets/app.js +++ b/internal/ui/assets/app.js @@ -238,9 +238,184 @@ if (!editor.contains(event.target)) close(); }); + attachLiveValidation(editor, textarea); loadCatalog(); } + function attachLiveValidation(editor, textarea) { + var highlight = editor.querySelector("[data-cel-highlight]"); + var fieldset = editor.closest("fieldset"); + var feedback = fieldset && fieldset.querySelector("[data-cel-validation-feedback]"); + if (!highlight || !feedback) return; + + var validationTimer; + var validationRequest; + var validationSequence = 0; + + function syncHighlightScroll() { + highlight.scrollTop = textarea.scrollTop; + highlight.scrollLeft = textarea.scrollLeft; + } + + function clearFeedback() { + feedback.replaceChildren(); + } + + function showChecking() { + clearFeedback(); + var checking = document.createElement("div"); + checking.className = "filter-validation-checking"; + checking.setAttribute("role", "status"); + checking.textContent = "Checking filters…"; + feedback.appendChild(checking); + } + + function showValid() { + clearFeedback(); + var valid = document.createElement("div"); + valid.className = "text-success text-sm"; + valid.setAttribute("role", "status"); + valid.textContent = "filters OK"; + feedback.appendChild(valid); + } + + function showDiagnostics(diagnostics) { + clearFeedback(); + var alert = document.createElement("div"); + alert.className = "alert alert-error filter-validation-errors"; + alert.setAttribute("role", "alert"); + var list = document.createElement("ul"); + diagnostics.forEach(function (diagnostic) { + var item = document.createElement("li"); + item.textContent = "Line " + diagnostic.line + ", column " + diagnostic.column + ": " + diagnostic.message; + list.appendChild(item); + }); + alert.appendChild(list); + feedback.appendChild(alert); + } + + function showUnavailable() { + clearFeedback(); + var alert = document.createElement("div"); + alert.className = "alert alert-warning filter-validation-errors"; + alert.setAttribute("role", "status"); + alert.textContent = "Live filter validation is temporarily unavailable. Save will still validate the filters."; + feedback.appendChild(alert); + } + + function renderHighlight(text, diagnostics) { + var ranges = diagnosticRanges(text, diagnostics); + var cursor = 0; + highlight.replaceChildren(); + ranges.forEach(function (range) { + if (range.start > cursor) { + highlight.appendChild(document.createTextNode(text.slice(cursor, range.start))); + } + var error = document.createElement("mark"); + error.className = "cel-filter-error"; + error.textContent = text.slice(range.start, range.end); + highlight.appendChild(error); + cursor = range.end; + }); + if (cursor < text.length) { + highlight.appendChild(document.createTextNode(text.slice(cursor))); + } + if (text === "" || text.endsWith("\n")) { + highlight.appendChild(document.createTextNode("\u200b")); + } + syncHighlightScroll(); + } + + function queueValidation() { + window.clearTimeout(validationTimer); + if (validationRequest) validationRequest.abort(); + validationRequest = null; + validationSequence++; + renderHighlight(textarea.value, []); + textarea.setAttribute("aria-invalid", "false"); + textarea.removeAttribute("aria-busy"); + + if (textarea.value.trim() === "") { + clearFeedback(); + return; + } + + showChecking(); + textarea.setAttribute("aria-busy", "true"); + var sequence = validationSequence; + validationTimer = window.setTimeout(function () { + validate(sequence, textarea.value); + }, 300); + } + + function validate(sequence, value) { + validationRequest = new AbortController(); + var body = new URLSearchParams(); + body.set("filters", value); + fetch("/ui/frag/validate-filters", { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded" + }, + body: body.toString(), + credentials: "same-origin", + signal: validationRequest.signal + }) + .then(function (response) { + if (!response.ok) throw new Error("validation request failed"); + return response.json(); + }) + .then(function (result) { + if (sequence !== validationSequence || textarea.value !== value) return; + var diagnostics = Array.isArray(result.diagnostics) ? result.diagnostics : []; + renderHighlight(value, diagnostics); + textarea.setAttribute("aria-busy", "false"); + textarea.setAttribute("aria-invalid", result.valid ? "false" : "true"); + if (result.valid) showValid(); + else showDiagnostics(diagnostics); + }) + .catch(function (error) { + if (error.name === "AbortError" || sequence !== validationSequence) return; + textarea.setAttribute("aria-busy", "false"); + textarea.setAttribute("aria-invalid", "false"); + renderHighlight(textarea.value, []); + showUnavailable(); + }); + } + + textarea.addEventListener("input", queueValidation); + textarea.addEventListener("scroll", syncHighlightScroll); + renderHighlight(textarea.value, []); + if (textarea.value.trim() !== "") queueValidation(); + } + + function diagnosticRanges(text, diagnostics) { + var ranges = (Array.isArray(diagnostics) ? diagnostics : []) + .map(function (diagnostic) { + var start = Math.max(0, Math.min(text.length, Number(diagnostic.start))); + var end = Math.max(start, Math.min(text.length, Number(diagnostic.end))); + if (end === start && start < text.length) end++; + return { start: start, end: end }; + }) + .filter(function (range) { + return range.end > range.start; + }) + .sort(function (left, right) { + return left.start - right.start || left.end - right.end; + }); + + return ranges.reduce(function (merged, range) { + var previous = merged[merged.length - 1]; + if (previous && range.start <= previous.end) { + previous.end = Math.max(previous.end, range.end); + } else { + merged.push(range); + } + return merged; + }, []); + } + function completionContext(value, cursor, manual) { var lineStart = value.lastIndexOf("\n", cursor - 1) + 1; var before = value.slice(lineStart, cursor); diff --git a/internal/ui/cel_validation.go b/internal/ui/cel_validation.go new file mode 100644 index 0000000..3c78909 --- /dev/null +++ b/internal/ui/cel_validation.go @@ -0,0 +1,134 @@ +package ui + +import ( + "encoding/json" + "log/slog" + "net/http" + "strings" + "unicode/utf16" + "unicode/utf8" + + "github.com/open-ships/beacon/internal/filter" +) + +type celValidationResponse struct { + Valid bool `json:"valid"` + Diagnostics []celValidationDiagnostic `json:"diagnostics"` +} + +type celValidationDiagnostic struct { + Start int `json:"start"` + End int `json:"end"` + Line int `json:"line"` + Column int `json:"column"` + Message string `json:"message"` +} + +type filterSourceLine struct { + expression string + physicalLine int + leadingBytes int + raw string + lineStartUTF16 int +} + +func acceptsJSON(r *http.Request) bool { + return strings.Contains(strings.ToLower(r.Header.Get("Accept")), "application/json") +} + +func renderCELValidationJSON(w http.ResponseWriter, log *slog.Logger, text string) { + response, err := buildCELValidationResponse(text) + if err != nil { + log.Error("ui: CEL validation failed", "err", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(response) +} + +func buildCELValidationResponse(text string) (celValidationResponse, error) { + lines := filterSourceLines(text) + expressions := make([]string, len(lines)) + for i, line := range lines { + expressions[i] = line.expression + } + diagnostics, err := filter.Diagnose(expressions) + if err != nil { + return celValidationResponse{}, err + } + + response := celValidationResponse{ + Valid: len(diagnostics) == 0, + Diagnostics: make([]celValidationDiagnostic, 0, len(diagnostics)), + } + for _, diagnostic := range diagnostics { + if diagnostic.Expression < 0 || diagnostic.Expression >= len(lines) { + continue + } + line := lines[diagnostic.Expression] + startByte := line.leadingBytes + diagnostic.Column + endByte := line.leadingBytes + diagnostic.EndColumn + start := line.lineStartUTF16 + utf16PrefixLength(line.raw, startByte) + end := line.lineStartUTF16 + utf16PrefixLength(line.raw, endByte) + response.Diagnostics = append(response.Diagnostics, celValidationDiagnostic{ + Start: start, + End: end, + Line: line.physicalLine + diagnostic.Line - 1, + Column: utf8PrefixLength(line.raw, startByte) + 1, + Message: diagnostic.Message, + }) + } + return response, nil +} + +func filterSourceLines(text string) []filterSourceLine { + rawLines := strings.Split(text, "\n") + lines := make([]filterSourceLine, 0, len(rawLines)) + lineStartUTF16 := 0 + for i, raw := range rawLines { + trimmed := strings.TrimSpace(raw) + if trimmed != "" { + lines = append(lines, filterSourceLine{ + expression: trimmed, + physicalLine: i + 1, + leadingBytes: strings.Index(raw, trimmed), + raw: raw, + lineStartUTF16: lineStartUTF16, + }) + } + lineStartUTF16 += utf16Length(raw) + if i < len(rawLines)-1 { + lineStartUTF16++ // textarea newlines are one UTF-16 code unit. + } + } + return lines +} + +func utf16PrefixLength(s string, byteOffset int) int { + byteOffset = runeBoundary(s, byteOffset) + return utf16Length(s[:byteOffset]) +} + +func utf8PrefixLength(s string, byteOffset int) int { + byteOffset = runeBoundary(s, byteOffset) + return utf8.RuneCountInString(s[:byteOffset]) +} + +func utf16Length(s string) int { + return len(utf16.Encode([]rune(s))) +} + +func runeBoundary(s string, byteOffset int) int { + if byteOffset < 0 { + return 0 + } + if byteOffset > len(s) { + return len(s) + } + for byteOffset > 0 && byteOffset < len(s) && !utf8.RuneStart(s[byteOffset]) { + byteOffset-- + } + return byteOffset +} diff --git a/internal/ui/docs/04-filters.md b/internal/ui/docs/04-filters.md index 2226648..c666f30 100644 --- a/internal/ui/docs/04-filters.md +++ b/internal/ui/docs/04-filters.md @@ -151,6 +151,7 @@ already arrive as CEL doubles.) `POST /api/v1/filters/validate` CEL-compiles a list of expressions without persisting anything (see the API page) — the same check the UI's connector -form runs on blur. A syntactically invalid expression is caught here (or by -the same check embedded in a connector `PUT`, which 422s) before it can -ever reach a running connector. +form runs automatically as you type. Compile errors are shown below the +editor and the offending token is underlined in red. The same check is also +embedded in a connector `PUT` (which 422s), so an invalid expression cannot +reach a running connector even if client-side validation is unavailable. diff --git a/internal/ui/forms.go b/internal/ui/forms.go index 4265ca2..7e4a5c8 100644 --- a/internal/ui/forms.go +++ b/internal/ui/forms.go @@ -1039,7 +1039,7 @@ func handleSinkDelete(svc *config.Service, log *slog.Logger) http.HandlerFunc { // (see forms.go's package doc comment): no type switch (source_id/sink_id // are plain +
+ + +
-

Start with msg. or press Ctrl+Space. Use arrow keys and Enter to complete.

-
+

Filters are checked as you type. Start with msg. or press Ctrl+Space; use arrow keys and Enter to complete.

+
Max messages diff --git a/internal/ui/templates/frag_filter_validate.html b/internal/ui/templates/frag_filter_validate.html index b6cade6..ec23de3 100644 --- a/internal/ui/templates/frag_filter_validate.html +++ b/internal/ui/templates/frag_filter_validate.html @@ -1,9 +1,8 @@ -{{/* Data: internal/ui/forms.go's filterValidateData. Rendered at POST - /ui/frag/validate-filters, the connector form's filters textarea - hx-post="blur changed" target (see frag_connector_form.html). Advisory - only — a "filters OK" here does not mean Save will succeed (e.g. an id - conflict can still fail it), and an error here does not block typing - or Save; svc.PutConnector re-validates authoritatively either way. */}} +{{/* Data: internal/ui/forms.go's filterValidateData. Rendered for non-JSON + POST /ui/frag/validate-filters callers. The enhanced connector editor + requests structured JSON from the same route; this fragment preserves + the endpoint's HTML contract for simple clients and tests. Advisory only: + svc.PutConnector re-validates authoritatively on Save. */}} {{define "filter-validate"}} {{if .OK}}
filters OK
diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 86f7686..9f40bc1 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -122,7 +122,7 @@ func Handler(svc *config.Service, reg *stats.Registry, statuses func() []supervi // Connectors: the list/add/edit/delete pages parallel sources/sinks // above, plus a per-connector overview page with live stats/streaming - // data and a CEL validate-on-blur fragment. See forms.go's + // data and live CEL validation diagnostics. See forms.go's // "--- Connectors ---" section for the behavior contract. mux.HandleFunc("GET /ui/connectors", handleConnectorsPage(svc, reg, assetVersion, log)) mux.HandleFunc("GET /ui/connectors/new", handleConnectorNewPage(svc, reg, assetVersion, log)) diff --git a/tests/browser/ui.spec.ts b/tests/browser/ui.spec.ts index ad95f06..2b3615f 100644 --- a/tests/browser/ui.spec.ts +++ b/tests/browser/ui.spec.ts @@ -26,7 +26,7 @@ test('operator can load the dashboard and create a source', async ({ page }) => await expect(page.getByRole('link', { name: sourceName })).toBeVisible(); }); -test('connector CEL filters provide typeahead and keyboard completion', async ({ page }) => { +test('connector CEL editor provides autocomplete and live diagnostics', async ({ page }) => { await page.goto('/ui/connectors/new'); const filters = page.locator('#conn-filters'); @@ -52,4 +52,19 @@ test('connector CEL filters provide typeahead and keyboard completion', async ({ await filters.press('Control+Space'); await expect(completions).toBeVisible(); await expect(page.getByRole('option', { name: /has\(\).*optional field/ })).toBeVisible(); + + await filters.fill('msg.pgn == @'); + await expect(filters).toBeFocused(); + await expect(filters).toHaveAttribute('aria-invalid', 'true'); + const badCharacter = page.locator('.cel-filter-error'); + await expect(badCharacter).toBeVisible(); + await expect(badCharacter).toHaveText('@'); + await expect(page.locator('#filter-feedback')).toContainText('token recognition error'); + await expect(badCharacter).toHaveCSS('text-decoration-style', 'wavy'); + await expect(badCharacter).toHaveCSS('text-decoration-color', 'rgb(180, 35, 24)'); + + await filters.fill('msg.pgn == 127250'); + await expect(filters).toHaveAttribute('aria-invalid', 'false'); + await expect(page.locator('.cel-filter-error')).toHaveCount(0); + await expect(page.locator('#filter-feedback')).toContainText('filters OK'); });