diff --git a/README.md b/README.md index 54b1d61..957a626 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ nginx-logtail/ ├── frontend/ │ ├── main.go │ ├── handler.go # URL param parsing, concurrent TopN+Trend, template exec + │ ├── filter.go # ParseFilterExpr / FilterExprString mini filter language │ ├── client.go # gRPC dial helper │ ├── sparkline.go # TrendPoints → inline SVG polyline │ ├── format.go # fmtCount (space thousands separator) @@ -145,11 +146,16 @@ and does not change any existing interface. ## Protobuf API (`proto/logtail.proto`) ```protobuf +enum StatusOp { EQ = 0; NE = 1; GT = 2; GE = 3; LT = 4; LE = 5; } + message Filter { optional string website = 1; optional string client_prefix = 2; optional string http_request_uri = 3; optional int32 http_response = 4; + StatusOp status_op = 5; // comparison operator for http_response + optional string website_regex = 6; // RE2 regex against website + optional string uri_regex = 7; // RE2 regex against http_request_uri } enum GroupBy { WEBSITE = 0; CLIENT_PREFIX = 1; REQUEST_URI = 2; HTTP_RESPONSE = 3; } @@ -262,8 +268,16 @@ service LogtailService { ### handler.go - All filter state in the **URL query string**: `w` (window), `by` (group_by), `f_website`, - `f_prefix`, `f_uri`, `f_status`, `n`, `target`. No server-side session — URLs are shareable - and bookmarkable; multiple operators see independent views. + `f_prefix`, `f_uri`, `f_status`, `f_website_re`, `f_uri_re`, `n`, `target`. No server-side + session — URLs are shareable and bookmarkable; multiple operators see independent views. +- **Filter expression box**: a `q=` parameter carries a mini filter language + (`status>=400 AND website~=gouda.* AND uri~=^/api/`). On submission the handler parses it + via `ParseFilterExpr` and redirects to the canonical URL with individual `f_*` params; `q=` + never appears in the final URL. Parse errors re-render the current page with an inline message. +- **Status expressions**: `f_status` accepts `200`, `!=200`, `>=400`, `<500`, etc. — parsed by + `store.ParseStatusExpr` into `(value, StatusOp)` for the filter protobuf. +- **Regex filters**: `f_website_re` and `f_uri_re` hold RE2 patterns; compiled once per request + into `store.CompiledFilter` before the query-loop iteration. Invalid regexes match nothing. - `TopN` and `Trend` RPCs issued **concurrently** (both with a 5 s deadline); page renders with whatever completes. Trend failure suppresses the sparkline without erroring the page. - **Drilldown**: clicking a table row adds the current dimension's filter and advances `by` through @@ -304,7 +318,9 @@ logtail-cli stream [flags] live snapshot feed (runs until Ctrl-C, auto-reconn | `--website` | — | Filter: website | | `--prefix` | — | Filter: client prefix | | `--uri` | — | Filter: request URI | -| `--status` | — | Filter: HTTP status code | +| `--status` | — | Filter: HTTP status expression (`200`, `!=200`, `>=400`, `<500`, …) | +| `--website-re`| — | Filter: RE2 regex against website | +| `--uri-re` | — | Filter: RE2 regex against request URI | **`topn` only**: `--n 10`, `--window 5m`, `--group-by website` @@ -345,3 +361,6 @@ with a non-zero code on gRPC error. | CLI default: human-readable table | Operator-friendly by default; `--json` opt-in for scripting | | CLI multi-target fan-out | Compare a collector vs. aggregator, or two collectors, in one command | | CLI uses stdlib `flag`, no framework | Four subcommands don't justify a dependency | +| Status filter as expression string (`!=200`, `>=400`) | Operator-friendly; parsed once at query boundary, encoded as `(int32, StatusOp)` in proto | +| Regex filters compiled once per query (`CompiledFilter`) | Up to 288 × 5 000 per-entry calls — compiling per-entry would dominate query latency | +| Filter expression box (`q=`) redirects to canonical URL | Filter state stays in individual `f_*` params; URLs remain shareable and bookmarkable | diff --git a/cmd/aggregator/cache.go b/cmd/aggregator/cache.go index 8d5816d..dcc4810 100644 --- a/cmd/aggregator/cache.go +++ b/cmd/aggregator/cache.go @@ -92,6 +92,7 @@ func (c *Cache) mergeFineBuckets(now time.Time) st.Snapshot { // QueryTopN answers a TopN request from the ring buffers. func (c *Cache) QueryTopN(filter *pb.Filter, groupBy pb.GroupBy, n int, window pb.Window) []st.Entry { + cf := st.CompileFilter(filter) c.mu.RLock() defer c.mu.RUnlock() @@ -101,7 +102,7 @@ func (c *Cache) QueryTopN(filter *pb.Filter, groupBy pb.GroupBy, n int, window p idx := (buckets.Head - 1 - i + buckets.Size) % buckets.Size for _, e := range buckets.Ring[idx].Entries { t := st.LabelTuple(e.Label) - if !st.MatchesFilter(t, filter) { + if !st.MatchesFilter(t, cf) { continue } grouped[st.DimensionLabel(t, groupBy)] += e.Count @@ -112,6 +113,7 @@ func (c *Cache) QueryTopN(filter *pb.Filter, groupBy pb.GroupBy, n int, window p // QueryTrend answers a Trend request from the ring buffers. func (c *Cache) QueryTrend(filter *pb.Filter, window pb.Window) []st.TrendPoint { + cf := st.CompileFilter(filter) c.mu.RLock() defer c.mu.RUnlock() @@ -122,7 +124,7 @@ func (c *Cache) QueryTrend(filter *pb.Filter, window pb.Window) []st.TrendPoint snap := buckets.Ring[idx] var total int64 for _, e := range snap.Entries { - if st.MatchesFilter(st.LabelTuple(e.Label), filter) { + if st.MatchesFilter(st.LabelTuple(e.Label), cf) { total += e.Count } } diff --git a/cmd/cli/flags.go b/cmd/cli/flags.go index 00b714f..32d1f55 100644 --- a/cmd/cli/flags.go +++ b/cmd/cli/flags.go @@ -4,20 +4,22 @@ import ( "flag" "fmt" "os" - "strconv" "strings" + st "git.ipng.ch/ipng/nginx-logtail/internal/store" pb "git.ipng.ch/ipng/nginx-logtail/proto/logtailpb" ) // sharedFlags holds the flags common to every subcommand. type sharedFlags struct { - targets []string - jsonOut bool - website string - prefix string - uri string - status string // kept as string so we can tell "unset" from "0" + targets []string + jsonOut bool + website string + prefix string + uri string + status string // expression: "200", "!=200", ">=400", etc. + websiteRe string // RE2 regex against website + uriRe string // RE2 regex against request URI } // bindShared registers the shared flags on fs and returns a pointer to the @@ -26,10 +28,12 @@ func bindShared(fs *flag.FlagSet) (*sharedFlags, *string) { sf := &sharedFlags{} target := fs.String("target", "localhost:9090", "comma-separated host:port list") fs.BoolVar(&sf.jsonOut, "json", false, "emit newline-delimited JSON") - fs.StringVar(&sf.website, "website", "", "filter: website") - fs.StringVar(&sf.prefix, "prefix", "", "filter: client prefix") - fs.StringVar(&sf.uri, "uri", "", "filter: request URI") - fs.StringVar(&sf.status, "status", "", "filter: HTTP status code (integer)") + fs.StringVar(&sf.website, "website", "", "filter: exact website match") + fs.StringVar(&sf.prefix, "prefix", "", "filter: exact client prefix match") + fs.StringVar(&sf.uri, "uri", "", "filter: exact request URI match") + fs.StringVar(&sf.status, "status", "", "filter: HTTP status expression (200, !=200, >=400, <500, …)") + fs.StringVar(&sf.websiteRe, "website-re", "", "filter: RE2 regex against website") + fs.StringVar(&sf.uriRe, "uri-re", "", "filter: RE2 regex against request URI") return sf, target } @@ -52,7 +56,7 @@ func parseTargets(s string) []string { } func buildFilter(sf *sharedFlags) *pb.Filter { - if sf.website == "" && sf.prefix == "" && sf.uri == "" && sf.status == "" { + if sf.website == "" && sf.prefix == "" && sf.uri == "" && sf.status == "" && sf.websiteRe == "" && sf.uriRe == "" { return nil } f := &pb.Filter{} @@ -66,13 +70,19 @@ func buildFilter(sf *sharedFlags) *pb.Filter { f.HttpRequestUri = &sf.uri } if sf.status != "" { - n, err := strconv.Atoi(sf.status) - if err != nil { - fmt.Fprintf(os.Stderr, "--status: %v\n", err) + n, op, ok := st.ParseStatusExpr(sf.status) + if !ok { + fmt.Fprintf(os.Stderr, "--status: invalid expression %q; use e.g. 200, !=200, >=400, <500\n", sf.status) os.Exit(1) } - n32 := int32(n) - f.HttpResponse = &n32 + f.HttpResponse = &n + f.StatusOp = op + } + if sf.websiteRe != "" { + f.WebsiteRegex = &sf.websiteRe + } + if sf.uriRe != "" { + f.UriRegex = &sf.uriRe } return f } diff --git a/cmd/cli/main.go b/cmd/cli/main.go index 67b946d..978c84c 100644 --- a/cmd/cli/main.go +++ b/cmd/cli/main.go @@ -18,7 +18,9 @@ Subcommand flags (all subcommands): --website STRING filter: exact website match --prefix STRING filter: exact client-prefix match --uri STRING filter: exact request URI match - --status INT filter: exact HTTP status code + --status EXPR filter: HTTP status expression (200, !=200, >=400, <500, …) + --website-re REGEX filter: RE2 regex against website + --uri-re REGEX filter: RE2 regex against request URI topn flags: --n INT number of entries (default 10) diff --git a/cmd/collector/store.go b/cmd/collector/store.go index bd0d2ea..ec0829f 100644 --- a/cmd/collector/store.go +++ b/cmd/collector/store.go @@ -97,6 +97,7 @@ func (s *Store) mergeFineBuckets(now time.Time) st.Snapshot { // QueryTopN answers a TopN request from the ring buffers. func (s *Store) QueryTopN(filter *pb.Filter, groupBy pb.GroupBy, n int, window pb.Window) []st.Entry { + cf := st.CompileFilter(filter) s.mu.RLock() defer s.mu.RUnlock() @@ -106,7 +107,7 @@ func (s *Store) QueryTopN(filter *pb.Filter, groupBy pb.GroupBy, n int, window p idx := (buckets.Head - 1 - i + buckets.Size) % buckets.Size for _, e := range buckets.Ring[idx].Entries { t := st.LabelTuple(e.Label) - if !st.MatchesFilter(t, filter) { + if !st.MatchesFilter(t, cf) { continue } grouped[st.DimensionLabel(t, groupBy)] += e.Count @@ -117,6 +118,7 @@ func (s *Store) QueryTopN(filter *pb.Filter, groupBy pb.GroupBy, n int, window p // QueryTrend answers a Trend request from the ring buffers. func (s *Store) QueryTrend(filter *pb.Filter, window pb.Window) []st.TrendPoint { + cf := st.CompileFilter(filter) s.mu.RLock() defer s.mu.RUnlock() @@ -127,7 +129,7 @@ func (s *Store) QueryTrend(filter *pb.Filter, window pb.Window) []st.TrendPoint snap := buckets.Ring[idx] var total int64 for _, e := range snap.Entries { - if st.MatchesFilter(st.LabelTuple(e.Label), filter) { + if st.MatchesFilter(st.LabelTuple(e.Label), cf) { total += e.Count } } diff --git a/cmd/frontend/filter.go b/cmd/frontend/filter.go new file mode 100644 index 0000000..777af64 --- /dev/null +++ b/cmd/frontend/filter.go @@ -0,0 +1,175 @@ +package main + +import ( + "fmt" + "regexp" + "strings" + + st "git.ipng.ch/ipng/nginx-logtail/internal/store" +) + +// andRe splits a filter expression on AND (case-insensitive, surrounded by whitespace). +var andRe = regexp.MustCompile(`(?i)\s+and\s+`) + +// ParseFilterExpr parses a mini filter expression into a filterState. +// +// Syntax: TERM [AND TERM ...] +// +// Terms: +// +// status=200 status!=200 status>=400 status>400 status<=500 status<500 +// website=example.com — exact match +// website~=gouda.* — RE2 regex +// uri=/api/v1/ — exact match +// uri~=^/api/.* — RE2 regex +// prefix=1.2.3.0/24 — exact match +// +// Values may be enclosed in double or single quotes. +func ParseFilterExpr(s string) (filterState, error) { + s = strings.TrimSpace(s) + if s == "" { + return filterState{}, nil + } + terms := andRe.Split(s, -1) + var fs filterState + for _, term := range terms { + term = strings.TrimSpace(term) + if term == "" { + continue + } + if err := applyTerm(term, &fs); err != nil { + return filterState{}, err + } + } + return fs, nil +} + +// applyTerm parses a single "field op value" term into fs. +func applyTerm(term string, fs *filterState) error { + // Find the first operator character: ~, !, >, <, = + opIdx := strings.IndexAny(term, "~!><=") + if opIdx <= 0 { + return fmt.Errorf("invalid term %q: expected field=value, field>=value, field~=regex, etc.", term) + } + + field := strings.ToLower(strings.TrimSpace(term[:opIdx])) + rest := term[opIdx:] + + var op, value string + switch { + case strings.HasPrefix(rest, "~="): + op, value = "~=", rest[2:] + case strings.HasPrefix(rest, "!="): + op, value = "!=", rest[2:] + case strings.HasPrefix(rest, ">="): + op, value = ">=", rest[2:] + case strings.HasPrefix(rest, "<="): + op, value = "<=", rest[2:] + case strings.HasPrefix(rest, ">"): + op, value = ">", rest[1:] + case strings.HasPrefix(rest, "<"): + op, value = "<", rest[1:] + case strings.HasPrefix(rest, "="): + op, value = "=", rest[1:] + default: + return fmt.Errorf("unrecognised operator in %q", term) + } + + value = unquote(strings.TrimSpace(value)) + + switch field { + case "status": + if op == "~=" { + return fmt.Errorf("status does not support ~=; use =, !=, >=, >, <=, <") + } + expr := op + value + if op == "=" { + expr = value // ParseStatusExpr accepts bare "200" + } + if _, _, ok := st.ParseStatusExpr(expr); !ok { + return fmt.Errorf("invalid status expression %q", expr) + } + fs.Status = expr + case "website": + switch op { + case "=": + fs.Website = value + case "~=": + fs.WebsiteRe = value + default: + return fmt.Errorf("website only supports = and ~=, not %q", op) + } + case "uri": + switch op { + case "=": + fs.URI = value + case "~=": + fs.URIRe = value + default: + return fmt.Errorf("uri only supports = and ~=, not %q", op) + } + case "prefix": + if op != "=" { + return fmt.Errorf("prefix only supports =, not %q", op) + } + fs.Prefix = value + default: + return fmt.Errorf("unknown field %q; valid: status, website, uri, prefix", field) + } + return nil +} + +// unquote strips surrounding double or single quotes. +func unquote(s string) string { + if len(s) >= 2 { + if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') { + return s[1 : len(s)-1] + } + } + return s +} + +// FilterExprString serialises a filterState back into the mini filter expression language. +// Returns "" when no filters are active. +func FilterExprString(f filterState) string { + var parts []string + if f.Website != "" { + parts = append(parts, "website="+quoteMaybe(f.Website)) + } + if f.WebsiteRe != "" { + parts = append(parts, "website~="+quoteMaybe(f.WebsiteRe)) + } + if f.Prefix != "" { + parts = append(parts, "prefix="+quoteMaybe(f.Prefix)) + } + if f.URI != "" { + parts = append(parts, "uri="+quoteMaybe(f.URI)) + } + if f.URIRe != "" { + parts = append(parts, "uri~="+quoteMaybe(f.URIRe)) + } + if f.Status != "" { + parts = append(parts, statusTermStr(f.Status)) + } + return strings.Join(parts, " AND ") +} + +// statusTermStr converts a stored status expression (">=400", "200") to a +// full filter term ("status>=400", "status=200"). +func statusTermStr(expr string) string { + if expr == "" { + return "" + } + if len(expr) > 0 && (expr[0] == '!' || expr[0] == '>' || expr[0] == '<') { + return "status" + expr + } + return "status=" + expr +} + +// quoteMaybe wraps s in double quotes when it contains spaces or quote characters. +func quoteMaybe(s string) string { + if strings.ContainsAny(s, " \t\"'") { + return `"` + strings.ReplaceAll(s, `"`, `\"`) + `"` + } + return s +} diff --git a/cmd/frontend/filter_test.go b/cmd/frontend/filter_test.go new file mode 100644 index 0000000..1a2f401 --- /dev/null +++ b/cmd/frontend/filter_test.go @@ -0,0 +1,260 @@ +package main + +import "testing" + +// --- ParseFilterExpr --- + +func TestParseEmpty(t *testing.T) { + fs, err := ParseFilterExpr("") + if err != nil { + t.Fatal(err) + } + if fs != (filterState{}) { + t.Fatalf("expected empty filterState, got %+v", fs) + } +} + +func TestParseStatusEQ(t *testing.T) { + fs, err := ParseFilterExpr("status=200") + if err != nil { + t.Fatal(err) + } + if fs.Status != "200" { + t.Fatalf("Status=%q", fs.Status) + } +} + +func TestParseStatusGE(t *testing.T) { + fs, err := ParseFilterExpr("status>=400") + if err != nil { + t.Fatal(err) + } + if fs.Status != ">=400" { + t.Fatalf("Status=%q", fs.Status) + } +} + +func TestParseStatusNE(t *testing.T) { + fs, err := ParseFilterExpr("status!=200") + if err != nil { + t.Fatal(err) + } + if fs.Status != "!=200" { + t.Fatalf("Status=%q", fs.Status) + } +} + +func TestParseStatusLT(t *testing.T) { + fs, err := ParseFilterExpr("status<500") + if err != nil { + t.Fatal(err) + } + if fs.Status != "<500" { + t.Fatalf("Status=%q", fs.Status) + } +} + +func TestParseWebsiteExact(t *testing.T) { + fs, err := ParseFilterExpr("website=example.com") + if err != nil { + t.Fatal(err) + } + if fs.Website != "example.com" { + t.Fatalf("Website=%q", fs.Website) + } +} + +func TestParseWebsiteRegex(t *testing.T) { + fs, err := ParseFilterExpr("website~=gouda.*") + if err != nil { + t.Fatal(err) + } + if fs.WebsiteRe != "gouda.*" { + t.Fatalf("WebsiteRe=%q", fs.WebsiteRe) + } +} + +func TestParseURIExact(t *testing.T) { + fs, err := ParseFilterExpr("uri=/api/v1/") + if err != nil { + t.Fatal(err) + } + if fs.URI != "/api/v1/" { + t.Fatalf("URI=%q", fs.URI) + } +} + +func TestParseURIRegex(t *testing.T) { + fs, err := ParseFilterExpr(`uri~=^/api/.*`) + if err != nil { + t.Fatal(err) + } + if fs.URIRe != `^/api/.*` { + t.Fatalf("URIRe=%q", fs.URIRe) + } +} + +func TestParsePrefix(t *testing.T) { + fs, err := ParseFilterExpr("prefix=1.2.3.0/24") + if err != nil { + t.Fatal(err) + } + if fs.Prefix != "1.2.3.0/24" { + t.Fatalf("Prefix=%q", fs.Prefix) + } +} + +func TestParseCombinedAND(t *testing.T) { + fs, err := ParseFilterExpr(`status>=400 AND website~=gouda.* AND uri~="^/.*"`) + if err != nil { + t.Fatal(err) + } + if fs.Status != ">=400" { + t.Fatalf("Status=%q", fs.Status) + } + if fs.WebsiteRe != "gouda.*" { + t.Fatalf("WebsiteRe=%q", fs.WebsiteRe) + } + if fs.URIRe != `^/.*` { // quotes stripped + t.Fatalf("URIRe=%q", fs.URIRe) + } +} + +func TestParseANDCaseInsensitive(t *testing.T) { + fs, err := ParseFilterExpr("status>=400 and website=example.com") + if err != nil { + t.Fatal(err) + } + if fs.Status != ">=400" || fs.Website != "example.com" { + t.Fatalf("%+v", fs) + } +} + +func TestParseQuotedValue(t *testing.T) { + fs, err := ParseFilterExpr(`website="example.com"`) + if err != nil { + t.Fatal(err) + } + if fs.Website != "example.com" { + t.Fatalf("Website=%q", fs.Website) + } +} + +func TestParseUnknownField(t *testing.T) { + _, err := ParseFilterExpr("host=foo") + if err == nil { + t.Fatal("expected error for unknown field") + } +} + +func TestParseStatusRegexRejected(t *testing.T) { + _, err := ParseFilterExpr("status~=4..") + if err == nil { + t.Fatal("expected error: status does not support ~=") + } +} + +func TestParseInvalidStatusExpr(t *testing.T) { + _, err := ParseFilterExpr("status>=abc") + if err == nil { + t.Fatal("expected error for non-numeric status") + } +} + +func TestParseMissingOperator(t *testing.T) { + _, err := ParseFilterExpr("status400") + if err == nil { + t.Fatal("expected error for missing operator") + } +} + +func TestParseWebsiteUnsupportedOp(t *testing.T) { + _, err := ParseFilterExpr("website>=example.com") + if err == nil { + t.Fatal("expected error: website does not support >=") + } +} + +// --- FilterExprString --- + +func TestFilterExprStringEmpty(t *testing.T) { + if s := FilterExprString(filterState{}); s != "" { + t.Fatalf("expected empty, got %q", s) + } +} + +func TestFilterExprStringStatus(t *testing.T) { + s := FilterExprString(filterState{Status: ">=400"}) + if s != "status>=400" { + t.Fatalf("got %q", s) + } +} + +func TestFilterExprStringStatusPlain(t *testing.T) { + s := FilterExprString(filterState{Status: "200"}) + if s != "status=200" { + t.Fatalf("got %q", s) + } +} + +func TestFilterExprStringWebsite(t *testing.T) { + s := FilterExprString(filterState{Website: "example.com"}) + if s != "website=example.com" { + t.Fatalf("got %q", s) + } +} + +func TestFilterExprStringWebsiteRegex(t *testing.T) { + s := FilterExprString(filterState{WebsiteRe: "gouda.*"}) + if s != "website~=gouda.*" { + t.Fatalf("got %q", s) + } +} + +func TestFilterExprStringCombined(t *testing.T) { + fs := filterState{Status: ">=400", WebsiteRe: "gouda.*", URIRe: `^/api/`} + s := FilterExprString(fs) + // Should contain all three parts joined by AND + if s == "" { + t.Fatal("expected non-empty") + } + // Round-trip: parse back + fs2, err := ParseFilterExpr(s) + if err != nil { + t.Fatalf("round-trip parse error: %v", err) + } + if fs2.Status != fs.Status || fs2.WebsiteRe != fs.WebsiteRe || fs2.URIRe != fs.URIRe { + t.Fatalf("round-trip mismatch: %+v vs %+v", fs, fs2) + } +} + +func TestFilterExprStringQuotesValue(t *testing.T) { + s := FilterExprString(filterState{Website: "has space"}) + if s != `website="has space"` { + t.Fatalf("got %q", s) + } +} + +func TestFilterExprRoundTrip(t *testing.T) { + cases := []filterState{ + {Status: "!=200"}, + {Status: "<500"}, + {Website: "example.com"}, + {WebsiteRe: "gouda.*"}, + {URI: "/api/v1/"}, + {URIRe: `^/api/`}, + {Prefix: "1.2.3.0/24"}, + {Status: ">=400", WebsiteRe: "gouda.*"}, + } + for _, fs := range cases { + expr := FilterExprString(fs) + fs2, err := ParseFilterExpr(expr) + if err != nil { + t.Errorf("round-trip parse error for %+v → %q: %v", fs, expr, err) + continue + } + if fs2 != fs { + t.Errorf("round-trip mismatch: %+v → %q → %+v", fs, expr, fs2) + } + } +} diff --git a/cmd/frontend/handler.go b/cmd/frontend/handler.go index 7dc40fc..bb8e6fe 100644 --- a/cmd/frontend/handler.go +++ b/cmd/frontend/handler.go @@ -11,6 +11,7 @@ import ( "strconv" "time" + st "git.ipng.ch/ipng/nginx-logtail/internal/store" pb "git.ipng.ch/ipng/nginx-logtail/proto/logtailpb" ) @@ -44,12 +45,14 @@ type TableRow struct { DrillURL string } -// filterState holds the four optional filter fields parsed from URL params. +// filterState holds the filter fields parsed from URL params. type filterState struct { - Website string - Prefix string - URI string - Status string // kept as string so empty means "unset" + Website string + Prefix string + URI string + Status string // expression: "200", "!=200", ">=400", etc. + WebsiteRe string // RE2 regex against website + URIRe string // RE2 regex against request URI } // QueryParams holds all parsed URL parameters for one page request. @@ -65,16 +68,19 @@ type QueryParams struct { // PageData is passed to the HTML template. type PageData struct { - Params QueryParams - Source string - Entries []TableRow - TotalCount int64 - Sparkline template.HTML - Breadcrumbs []Crumb - Windows []Tab - GroupBys []Tab - RefreshSecs int - Error string + Params QueryParams + Source string + Entries []TableRow + TotalCount int64 + Sparkline template.HTML + Breadcrumbs []Crumb + Windows []Tab + GroupBys []Tab + RefreshSecs int + Error string + FilterExpr string // current filter serialised to mini-language for the input box + FilterErr string // parse error from a submitted q= expression + ClearFilterURL string // URL that removes all filter params } var windowSpecs = []struct{ s, label string }{ @@ -143,16 +149,18 @@ func (h *Handler) parseParams(r *http.Request) QueryParams { GroupByS: grpS, N: n, Filter: filterState{ - Website: q.Get("f_website"), - Prefix: q.Get("f_prefix"), - URI: q.Get("f_uri"), - Status: q.Get("f_status"), + Website: q.Get("f_website"), + Prefix: q.Get("f_prefix"), + URI: q.Get("f_uri"), + Status: q.Get("f_status"), + WebsiteRe: q.Get("f_website_re"), + URIRe: q.Get("f_uri_re"), }, } } func buildFilter(f filterState) *pb.Filter { - if f.Website == "" && f.Prefix == "" && f.URI == "" && f.Status == "" { + if f.Website == "" && f.Prefix == "" && f.URI == "" && f.Status == "" && f.WebsiteRe == "" && f.URIRe == "" { return nil } out := &pb.Filter{} @@ -166,11 +174,17 @@ func buildFilter(f filterState) *pb.Filter { out.HttpRequestUri = &f.URI } if f.Status != "" { - if n, err := strconv.Atoi(f.Status); err == nil { - n32 := int32(n) - out.HttpResponse = &n32 + if n, op, ok := st.ParseStatusExpr(f.Status); ok { + out.HttpResponse = &n + out.StatusOp = op } } + if f.WebsiteRe != "" { + out.WebsiteRegex = &f.WebsiteRe + } + if f.URIRe != "" { + out.UriRegex = &f.URIRe + } return out } @@ -193,6 +207,12 @@ func (p QueryParams) toValues() url.Values { if p.Filter.Status != "" { v.Set("f_status", p.Filter.Status) } + if p.Filter.WebsiteRe != "" { + v.Set("f_website_re", p.Filter.WebsiteRe) + } + if p.Filter.URIRe != "" { + v.Set("f_uri_re", p.Filter.URIRe) + } return v } @@ -210,6 +230,14 @@ func (p QueryParams) buildURL(overrides map[string]string) string { return "/?" + v.Encode() } +// clearFilterURL returns a URL with all filter params removed. +func (p QueryParams) clearFilterURL() string { + return p.buildURL(map[string]string{ + "f_website": "", "f_prefix": "", "f_uri": "", "f_status": "", + "f_website_re": "", "f_uri_re": "", + }) +} + // nextGroupBy advances the drill-down dimension hierarchy (cycles at the end). func nextGroupBy(s string) string { switch s { @@ -273,6 +301,18 @@ func buildCrumbs(p QueryParams) []Crumb { RemoveURL: p.buildURL(map[string]string{"f_status": ""}), }) } + if p.Filter.WebsiteRe != "" { + crumbs = append(crumbs, Crumb{ + Text: "website~=" + p.Filter.WebsiteRe, + RemoveURL: p.buildURL(map[string]string{"f_website_re": ""}), + }) + } + if p.Filter.URIRe != "" { + crumbs = append(crumbs, Crumb{ + Text: "uri~=" + p.Filter.URIRe, + RemoveURL: p.buildURL(map[string]string{"f_uri_re": ""}), + }) + } return crumbs } @@ -326,6 +366,27 @@ func buildTableRows(entries []*pb.TopNEntry, p QueryParams) ([]TableRow, int64) func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { params := h.parseParams(r) + + // Handle filter expression box submission (q= param). + var filterErr string + filterExprInput := FilterExprString(params.Filter) + if qVals, ok := r.URL.Query()["q"]; ok { + q := "" + if len(qVals) > 0 { + q = qVals[0] + } + fs, err := ParseFilterExpr(q) + if err != nil { + filterErr = err.Error() + filterExprInput = q // show what the user typed so they can fix it + // fall through: render page using existing filter params + } else { + params.Filter = fs + http.Redirect(w, r, params.buildURL(nil), http.StatusSeeOther) + return + } + } + filter := buildFilter(params.Filter) conn, client, err := dial(params.Target) @@ -390,15 +451,18 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } data := PageData{ - Params: params, - Source: tn.resp.Source, - Entries: rows, - TotalCount: total, - Sparkline: sparkline, - Breadcrumbs: buildCrumbs(params), - Windows: buildWindowTabs(params), - GroupBys: buildGroupByTabs(params), - RefreshSecs: h.refreshSecs, + Params: params, + Source: tn.resp.Source, + Entries: rows, + TotalCount: total, + Sparkline: sparkline, + Breadcrumbs: buildCrumbs(params), + Windows: buildWindowTabs(params), + GroupBys: buildGroupByTabs(params), + RefreshSecs: h.refreshSecs, + FilterExpr: filterExprInput, + FilterErr: filterErr, + ClearFilterURL: params.clearFilterURL(), } h.render(w, http.StatusOK, data) } @@ -413,12 +477,14 @@ func (h *Handler) render(w http.ResponseWriter, status int, data PageData) { func (h *Handler) errorPage(params QueryParams, msg string) PageData { return PageData{ - Params: params, - Windows: buildWindowTabs(params), - GroupBys: buildGroupByTabs(params), - Breadcrumbs: buildCrumbs(params), - RefreshSecs: h.refreshSecs, - Error: msg, + Params: params, + Windows: buildWindowTabs(params), + GroupBys: buildGroupByTabs(params), + Breadcrumbs: buildCrumbs(params), + RefreshSecs: h.refreshSecs, + Error: msg, + FilterExpr: FilterExprString(params.Filter), + ClearFilterURL: params.clearFilterURL(), } } diff --git a/cmd/frontend/templates/base.html b/cmd/frontend/templates/base.html index 0b17a28..614bb24 100644 --- a/cmd/frontend/templates/base.html +++ b/cmd/frontend/templates/base.html @@ -34,6 +34,12 @@ a:hover { text-decoration: underline; } .error { color: #c00; border: 1px solid #fbb; background: #fff5f5; padding: 0.7em 1em; margin: 1em 0; border-radius: 3px; } .nodata { color: #999; margin: 2em 0; font-style: italic; } footer { margin-top: 2em; padding-top: 0.6em; border-top: 1px solid #e0e0e0; font-size: 0.8em; color: #999; } +.filter-form { display: flex; gap: 0.4em; align-items: center; margin-bottom: 0.7em; } +.filter-input { flex: 1; font-family: monospace; font-size: 13px; padding: 0.25em 0.5em; border: 1px solid #aaa; } +.filter-form button { padding: 0.25em 0.8em; border: 1px solid #aaa; background: #f4f4f4; cursor: pointer; font-family: monospace; } +.filter-form button:hover { background: #e8e8e8; } +.filter-form .clear { color: #c00; font-size: 0.9em; white-space: nowrap; } +.filter-err { color: #c00; font-size: 0.85em; margin: -0.3em 0 0.6em; }
diff --git a/cmd/frontend/templates/index.html b/cmd/frontend/templates/index.html index 2d29cbb..ec223df 100644 --- a/cmd/frontend/templates/index.html +++ b/cmd/frontend/templates/index.html @@ -13,6 +13,17 @@ {{- end}} + +{{- if .FilterErr}}