Implement filter in status, website and uri in CLI and Frontend
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
175
cmd/frontend/filter.go
Normal file
175
cmd/frontend/filter.go
Normal file
@@ -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
|
||||
}
|
||||
260
cmd/frontend/filter_test.go
Normal file
260
cmd/frontend/filter_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -13,6 +13,17 @@
|
||||
{{- end}}
|
||||
</div>
|
||||
|
||||
<form class="filter-form" method="get" action="/">
|
||||
<input type="hidden" name="target" value="{{.Params.Target}}">
|
||||
<input type="hidden" name="w" value="{{.Params.WindowS}}">
|
||||
<input type="hidden" name="by" value="{{.Params.GroupByS}}">
|
||||
<input type="hidden" name="n" value="{{.Params.N}}">
|
||||
<input class="filter-input" type="text" name="q" value="{{.FilterExpr}}" placeholder="status>=400 AND website~=gouda.* AND uri~=^/api/">
|
||||
<button type="submit">filter</button>
|
||||
{{- if .FilterExpr}} <a class="clear" href="{{.ClearFilterURL}}">× clear</a>{{end}}
|
||||
</form>
|
||||
{{- if .FilterErr}}<div class="filter-err">{{.FilterErr}}</div>{{end}}
|
||||
|
||||
{{if .Breadcrumbs}}
|
||||
<div class="crumbs">
|
||||
<span class="label">Filters:</span>
|
||||
|
||||
Reference in New Issue
Block a user