Implement filter in status, website and uri in CLI and Frontend

This commit is contained in:
2026-03-14 21:59:30 +01:00
parent 2962590a74
commit afa65a2b29
15 changed files with 1159 additions and 123 deletions

175
cmd/frontend/filter.go Normal file
View 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
View 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)
}
}
}

View File

@@ -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(),
}
}

View File

@@ -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>

View File

@@ -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>