Execute PLAN_FRONTEND.md

This commit is contained in:
2026-03-14 20:42:51 +01:00
parent b9ec67ec00
commit 4369e66dee
9 changed files with 1571 additions and 0 deletions

447
cmd/frontend/handler.go Normal file
View File

@@ -0,0 +1,447 @@
package main
import (
"context"
"encoding/json"
"fmt"
"html/template"
"log"
"net/http"
"net/url"
"strconv"
"time"
pb "git.ipng.ch/ipng/nginx-logtail/proto/logtailpb"
)
// Handler is the HTTP handler for the frontend.
type Handler struct {
defaultTarget string
defaultN int
refreshSecs int
tmpl *template.Template
}
// Tab is a window or group-by selector link.
type Tab struct {
Label string
URL string
Active bool
}
// Crumb is one active filter shown in the breadcrumb strip.
type Crumb struct {
Text string
RemoveURL string
}
// TableRow is one row in the TopN result table.
type TableRow struct {
Rank int
Label string
Count int64
Pct float64 // 0100, relative to rank-1 entry
DrillURL string
}
// filterState holds the four optional filter fields parsed from URL params.
type filterState struct {
Website string
Prefix string
URI string
Status string // kept as string so empty means "unset"
}
// QueryParams holds all parsed URL parameters for one page request.
type QueryParams struct {
Target string
Window pb.Window
WindowS string // e.g. "5m"
GroupBy pb.GroupBy
GroupByS string // e.g. "website"
N int
Filter filterState
}
// 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
}
var windowSpecs = []struct{ s, label string }{
{"1m", "1m"}, {"5m", "5m"}, {"15m", "15m"}, {"60m", "60m"}, {"6h", "6h"}, {"24h", "24h"},
}
var groupBySpecs = []struct{ s, label string }{
{"website", "website"}, {"prefix", "prefix"}, {"uri", "uri"}, {"status", "status"},
}
func parseWindowString(s string) (pb.Window, string) {
switch s {
case "1m":
return pb.Window_W1M, "1m"
case "5m":
return pb.Window_W5M, "5m"
case "15m":
return pb.Window_W15M, "15m"
case "60m":
return pb.Window_W60M, "60m"
case "6h":
return pb.Window_W6H, "6h"
case "24h":
return pb.Window_W24H, "24h"
default:
return pb.Window_W5M, "5m"
}
}
func parseGroupByString(s string) (pb.GroupBy, string) {
switch s {
case "prefix":
return pb.GroupBy_CLIENT_PREFIX, "prefix"
case "uri":
return pb.GroupBy_REQUEST_URI, "uri"
case "status":
return pb.GroupBy_HTTP_RESPONSE, "status"
default:
return pb.GroupBy_WEBSITE, "website"
}
}
func (h *Handler) parseParams(r *http.Request) QueryParams {
q := r.URL.Query()
target := q.Get("target")
if target == "" {
target = h.defaultTarget
}
win, winS := parseWindowString(q.Get("w"))
grp, grpS := parseGroupByString(q.Get("by"))
n := h.defaultN
if ns := q.Get("n"); ns != "" {
if v, err := strconv.Atoi(ns); err == nil && v > 0 {
n = v
}
}
return QueryParams{
Target: target,
Window: win,
WindowS: winS,
GroupBy: grp,
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"),
},
}
}
func buildFilter(f filterState) *pb.Filter {
if f.Website == "" && f.Prefix == "" && f.URI == "" && f.Status == "" {
return nil
}
out := &pb.Filter{}
if f.Website != "" {
out.Website = &f.Website
}
if f.Prefix != "" {
out.ClientPrefix = &f.Prefix
}
if f.URI != "" {
out.HttpRequestUri = &f.URI
}
if f.Status != "" {
if n, err := strconv.Atoi(f.Status); err == nil {
n32 := int32(n)
out.HttpResponse = &n32
}
}
return out
}
// toValues serialises QueryParams back to URL query values.
func (p QueryParams) toValues() url.Values {
v := url.Values{}
v.Set("target", p.Target)
v.Set("w", p.WindowS)
v.Set("by", p.GroupByS)
v.Set("n", strconv.Itoa(p.N))
if p.Filter.Website != "" {
v.Set("f_website", p.Filter.Website)
}
if p.Filter.Prefix != "" {
v.Set("f_prefix", p.Filter.Prefix)
}
if p.Filter.URI != "" {
v.Set("f_uri", p.Filter.URI)
}
if p.Filter.Status != "" {
v.Set("f_status", p.Filter.Status)
}
return v
}
// buildURL returns a page URL derived from the current params with overrides applied.
// An override value of "" removes that key from the URL.
func (p QueryParams) buildURL(overrides map[string]string) string {
v := p.toValues()
for k, val := range overrides {
if val == "" {
v.Del(k)
} else {
v.Set(k, val)
}
}
return "/?" + v.Encode()
}
// nextGroupBy advances the drill-down dimension hierarchy (cycles at the end).
func nextGroupBy(s string) string {
switch s {
case "website":
return "prefix"
case "prefix":
return "uri"
case "uri":
return "status"
default: // status → back to website
return "website"
}
}
// groupByFilterKey maps a group-by name to its URL filter parameter.
func groupByFilterKey(s string) string {
switch s {
case "website":
return "f_website"
case "prefix":
return "f_prefix"
case "uri":
return "f_uri"
case "status":
return "f_status"
default:
return "f_website"
}
}
func (p QueryParams) drillURL(label string) string {
return p.buildURL(map[string]string{
groupByFilterKey(p.GroupByS): label,
"by": nextGroupBy(p.GroupByS),
})
}
func buildCrumbs(p QueryParams) []Crumb {
var crumbs []Crumb
if p.Filter.Website != "" {
crumbs = append(crumbs, Crumb{
Text: "website=" + p.Filter.Website,
RemoveURL: p.buildURL(map[string]string{"f_website": ""}),
})
}
if p.Filter.Prefix != "" {
crumbs = append(crumbs, Crumb{
Text: "prefix=" + p.Filter.Prefix,
RemoveURL: p.buildURL(map[string]string{"f_prefix": ""}),
})
}
if p.Filter.URI != "" {
crumbs = append(crumbs, Crumb{
Text: "uri=" + p.Filter.URI,
RemoveURL: p.buildURL(map[string]string{"f_uri": ""}),
})
}
if p.Filter.Status != "" {
crumbs = append(crumbs, Crumb{
Text: "status=" + p.Filter.Status,
RemoveURL: p.buildURL(map[string]string{"f_status": ""}),
})
}
return crumbs
}
func buildWindowTabs(p QueryParams) []Tab {
tabs := make([]Tab, len(windowSpecs))
for i, w := range windowSpecs {
tabs[i] = Tab{
Label: w.label,
URL: p.buildURL(map[string]string{"w": w.s}),
Active: p.WindowS == w.s,
}
}
return tabs
}
func buildGroupByTabs(p QueryParams) []Tab {
tabs := make([]Tab, len(groupBySpecs))
for i, g := range groupBySpecs {
tabs[i] = Tab{
Label: "by " + g.label,
URL: p.buildURL(map[string]string{"by": g.s}),
Active: p.GroupByS == g.s,
}
}
return tabs
}
func buildTableRows(entries []*pb.TopNEntry, p QueryParams) ([]TableRow, int64) {
if len(entries) == 0 {
return nil, 0
}
top := float64(entries[0].Count)
var total int64
rows := make([]TableRow, len(entries))
for i, e := range entries {
total += e.Count
pct := 0.0
if top > 0 {
pct = float64(e.Count) / top * 100
}
rows[i] = TableRow{
Rank: i + 1,
Label: e.Label,
Count: e.Count,
Pct: pct,
DrillURL: p.drillURL(e.Label),
}
}
return rows, total
}
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
params := h.parseParams(r)
filter := buildFilter(params.Filter)
conn, client, err := dial(params.Target)
if err != nil {
h.render(w, http.StatusBadGateway, h.errorPage(params,
fmt.Sprintf("cannot connect to %s: %v", params.Target, err)))
return
}
defer conn.Close()
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
type topNResult struct {
resp *pb.TopNResponse
err error
}
type trendResult struct {
resp *pb.TrendResponse
err error
}
topNCh := make(chan topNResult, 1)
trendCh := make(chan trendResult, 1)
go func() {
resp, err := client.TopN(ctx, &pb.TopNRequest{
Filter: filter,
GroupBy: params.GroupBy,
N: int32(params.N),
Window: params.Window,
})
topNCh <- topNResult{resp, err}
}()
go func() {
resp, err := client.Trend(ctx, &pb.TrendRequest{
Filter: filter,
Window: params.Window,
})
trendCh <- trendResult{resp, err}
}()
tn := <-topNCh
tr := <-trendCh
if tn.err != nil {
h.render(w, http.StatusBadGateway, h.errorPage(params,
fmt.Sprintf("error querying %s: %v", params.Target, tn.err)))
return
}
// raw=1: return JSON for scripting
if r.URL.Query().Get("raw") == "1" {
writeRawJSON(w, params, tn.resp)
return
}
rows, total := buildTableRows(tn.resp.Entries, params)
var sparkline template.HTML
if tr.err == nil && tr.resp != nil {
sparkline = renderSparkline(tr.resp.Points)
}
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,
}
h.render(w, http.StatusOK, data)
}
func (h *Handler) render(w http.ResponseWriter, status int, data PageData) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
if err := h.tmpl.ExecuteTemplate(w, "base", data); err != nil {
log.Printf("frontend: template error: %v", err)
}
}
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,
}
}
func writeRawJSON(w http.ResponseWriter, params QueryParams, resp *pb.TopNResponse) {
type entry struct {
Label string `json:"label"`
Count int64 `json:"count"`
}
type out struct {
Source string `json:"source"`
Window string `json:"window"`
GroupBy string `json:"group_by"`
Entries []entry `json:"entries"`
}
o := out{
Source: resp.Source,
Window: params.WindowS,
GroupBy: params.GroupByS,
Entries: make([]entry, len(resp.Entries)),
}
for i, e := range resp.Entries {
o.Entries[i] = entry{Label: e.Label, Count: e.Count}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(o)
}