Execute PLAN_CLI.md

This commit is contained in:
2026-03-14 20:30:23 +01:00
parent 76612c1cb8
commit b9ec67ec00
9 changed files with 1310 additions and 0 deletions

381
cmd/cli/cli_test.go Normal file
View File

@@ -0,0 +1,381 @@
package main
import (
"bytes"
"context"
"encoding/json"
"net"
"strings"
"testing"
"time"
pb "git.ipng.ch/ipng/nginx-logtail/proto/logtailpb"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
// --- Unit tests ---
func TestParseTargets(t *testing.T) {
got := parseTargets("a:1, b:2, a:1, , c:3")
want := []string{"a:1", "b:2", "c:3"}
if len(got) != len(want) {
t.Fatalf("got %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("[%d] got %q, want %q", i, got[i], want[i])
}
}
}
func TestParseWindow(t *testing.T) {
cases := []struct {
s string
want pb.Window
}{
{"1m", pb.Window_W1M},
{"5m", pb.Window_W5M},
{"15m", pb.Window_W15M},
{"60m", pb.Window_W60M},
{"6h", pb.Window_W6H},
{"24h", pb.Window_W24H},
}
for _, c := range cases {
if got := parseWindow(c.s); got != c.want {
t.Errorf("parseWindow(%q) = %v, want %v", c.s, got, c.want)
}
}
}
func TestParseGroupBy(t *testing.T) {
cases := []struct {
s string
want pb.GroupBy
}{
{"website", pb.GroupBy_WEBSITE},
{"prefix", pb.GroupBy_CLIENT_PREFIX},
{"uri", pb.GroupBy_REQUEST_URI},
{"status", pb.GroupBy_HTTP_RESPONSE},
}
for _, c := range cases {
if got := parseGroupBy(c.s); got != c.want {
t.Errorf("parseGroupBy(%q) = %v, want %v", c.s, got, c.want)
}
}
}
func TestBuildFilter(t *testing.T) {
sf := &sharedFlags{website: "example.com", status: "404"}
f := buildFilter(sf)
if f == nil {
t.Fatal("expected non-nil filter")
}
if f.GetWebsite() != "example.com" {
t.Errorf("website = %q", f.GetWebsite())
}
if f.GetHttpResponse() != 404 {
t.Errorf("status = %d", f.GetHttpResponse())
}
if f.ClientPrefix != nil {
t.Error("expected nil client prefix")
}
}
func TestBuildFilterNil(t *testing.T) {
if buildFilter(&sharedFlags{}) != nil {
t.Error("expected nil filter when no flags set")
}
}
func TestFmtCount(t *testing.T) {
cases := []struct{ n int64; want string }{
{0, "0"},
{999, "999"},
{1000, "1 000"},
{18432, "18 432"},
{1234567, "1 234 567"},
}
for _, c := range cases {
if got := fmtCount(c.n); got != c.want {
t.Errorf("fmtCount(%d) = %q, want %q", c.n, got, c.want)
}
}
}
func TestFmtTime(t *testing.T) {
ts := time.Date(2026, 3, 14, 20, 0, 0, 0, time.UTC).Unix()
got := fmtTime(ts)
if got != "2026-03-14 20:00" {
t.Errorf("fmtTime = %q", got)
}
}
// --- Fake gRPC server helpers ---
type fakeServer struct {
pb.UnimplementedLogtailServiceServer
topNResp *pb.TopNResponse
trendResp *pb.TrendResponse
snaps []*pb.Snapshot
}
func (f *fakeServer) TopN(_ context.Context, _ *pb.TopNRequest) (*pb.TopNResponse, error) {
return f.topNResp, nil
}
func (f *fakeServer) Trend(_ context.Context, _ *pb.TrendRequest) (*pb.TrendResponse, error) {
return f.trendResp, nil
}
func (f *fakeServer) StreamSnapshots(_ *pb.SnapshotRequest, stream grpc.ServerStreamingServer[pb.Snapshot]) error {
for _, s := range f.snaps {
if err := stream.Send(s); err != nil {
return err
}
}
<-stream.Context().Done()
return nil
}
func startFake(t *testing.T, fs *fakeServer) string {
t.Helper()
lis, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
srv := grpc.NewServer()
pb.RegisterLogtailServiceServer(srv, fs)
go srv.Serve(lis)
t.Cleanup(srv.GracefulStop)
return lis.Addr().String()
}
func dialTest(t *testing.T, addr string) pb.LogtailServiceClient {
t.Helper()
conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { conn.Close() })
return pb.NewLogtailServiceClient(conn)
}
// --- TopN tests ---
func TestTopNSingleTarget(t *testing.T) {
addr := startFake(t, &fakeServer{
topNResp: &pb.TopNResponse{
Source: "col-1",
Entries: []*pb.TopNEntry{
{Label: "busy.com", Count: 18432},
{Label: "quiet.com", Count: 100},
},
},
})
results := fanOutTopN([]string{addr}, nil, pb.GroupBy_WEBSITE, 10, pb.Window_W5M)
if len(results) != 1 {
t.Fatalf("expected 1 result, got %d", len(results))
}
r := results[0]
if r.err != nil {
t.Fatalf("unexpected error: %v", r.err)
}
if len(r.resp.Entries) != 2 {
t.Fatalf("expected 2 entries, got %d", len(r.resp.Entries))
}
if r.resp.Entries[0].Label != "busy.com" {
t.Errorf("top label = %q", r.resp.Entries[0].Label)
}
if r.resp.Entries[0].Count != 18432 {
t.Errorf("top count = %d", r.resp.Entries[0].Count)
}
}
func TestTopNMultiTarget(t *testing.T) {
addr1 := startFake(t, &fakeServer{
topNResp: &pb.TopNResponse{Source: "col-1", Entries: []*pb.TopNEntry{{Label: "a.com", Count: 100}}},
})
addr2 := startFake(t, &fakeServer{
topNResp: &pb.TopNResponse{Source: "col-2", Entries: []*pb.TopNEntry{{Label: "b.com", Count: 200}}},
})
results := fanOutTopN([]string{addr1, addr2}, nil, pb.GroupBy_WEBSITE, 10, pb.Window_W5M)
if len(results) != 2 {
t.Fatalf("expected 2 results, got %d", len(results))
}
if results[0].resp.Source != "col-1" {
t.Errorf("result[0].source = %q", results[0].resp.Source)
}
if results[1].resp.Source != "col-2" {
t.Errorf("result[1].source = %q", results[1].resp.Source)
}
}
func TestTopNJSON(t *testing.T) {
addr := startFake(t, &fakeServer{
topNResp: &pb.TopNResponse{
Source: "agg",
Entries: []*pb.TopNEntry{{Label: "x.com", Count: 42}},
},
})
results := fanOutTopN([]string{addr}, nil, pb.GroupBy_WEBSITE, 10, pb.Window_W5M)
var buf bytes.Buffer
// Redirect stdout not needed; call JSON formatter directly.
r := results[0]
// Build expected JSON by calling printTopNJSON with a captured stdout.
// We test indirectly: marshal manually and compare fields.
type entry struct {
Label string `json:"label"`
Count int64 `json:"count"`
}
type out struct {
Source string `json:"source"`
Target string `json:"target"`
Entries []entry `json:"entries"`
}
_ = buf
_ = r
// Verify the response fields are correct for JSON serialization.
if r.resp.Source != "agg" {
t.Errorf("source = %q", r.resp.Source)
}
if len(r.resp.Entries) != 1 || r.resp.Entries[0].Label != "x.com" {
t.Errorf("entries = %v", r.resp.Entries)
}
}
// --- Trend tests ---
func TestTrendSingleTarget(t *testing.T) {
addr := startFake(t, &fakeServer{
trendResp: &pb.TrendResponse{
Source: "col-1",
Points: []*pb.TrendPoint{
{TimestampUnix: 1773516000, Count: 823},
{TimestampUnix: 1773516060, Count: 941},
},
},
})
results := fanOutTrend([]string{addr}, nil, pb.Window_W5M)
if len(results) != 1 {
t.Fatalf("expected 1 result, got %d", len(results))
}
r := results[0]
if r.err != nil {
t.Fatalf("unexpected error: %v", r.err)
}
if len(r.resp.Points) != 2 {
t.Fatalf("expected 2 points, got %d", len(r.resp.Points))
}
if r.resp.Points[0].Count != 823 {
t.Errorf("points[0].count = %d", r.resp.Points[0].Count)
}
// Verify oldest-first ordering (as returned by server).
if r.resp.Points[0].TimestampUnix > r.resp.Points[1].TimestampUnix {
t.Error("points not in ascending timestamp order")
}
}
func TestTrendJSON(t *testing.T) {
addr := startFake(t, &fakeServer{
trendResp: &pb.TrendResponse{
Source: "col-1",
Points: []*pb.TrendPoint{{TimestampUnix: 1773516000, Count: 500}},
},
})
results := fanOutTrend([]string{addr}, nil, pb.Window_W5M)
r := results[0]
// Build the JSON the same way printTrendJSON would and verify it parses.
type point struct {
Ts int64 `json:"ts"`
Count int64 `json:"count"`
}
type out struct {
Source string `json:"source"`
Target string `json:"target"`
Points []point `json:"points"`
}
o := out{
Source: r.resp.Source,
Target: r.target,
Points: []point{{Ts: r.resp.Points[0].TimestampUnix, Count: r.resp.Points[0].Count}},
}
b, err := json.Marshal(o)
if err != nil {
t.Fatal(err)
}
var parsed out
if err := json.Unmarshal(b, &parsed); err != nil {
t.Fatalf("JSON round-trip: %v", err)
}
if parsed.Source != "col-1" {
t.Errorf("source = %q", parsed.Source)
}
if len(parsed.Points) != 1 || parsed.Points[0].Count != 500 {
t.Errorf("points = %v", parsed.Points)
}
}
// --- Stream tests ---
func TestStreamReceivesSnapshots(t *testing.T) {
snaps := []*pb.Snapshot{
{Source: "col-1", Timestamp: 1773516000, Entries: []*pb.TopNEntry{{Label: "a.com", Count: 10}}},
{Source: "col-1", Timestamp: 1773516060, Entries: []*pb.TopNEntry{{Label: "b.com", Count: 20}}},
{Source: "col-1", Timestamp: 1773516120, Entries: []*pb.TopNEntry{{Label: "c.com", Count: 30}}},
}
addr := startFake(t, &fakeServer{snaps: snaps})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
events := make(chan streamEvent, 8)
go streamTarget(ctx, addr, nil, events)
var received []*pb.Snapshot
for len(received) < 3 {
select {
case <-ctx.Done():
t.Fatalf("timed out; only received %d snapshots", len(received))
case ev := <-events:
if ev.err != nil {
// After the 3 snaps the server blocks; context will cancel it.
continue
}
received = append(received, ev.snap)
}
}
if len(received) != 3 {
t.Fatalf("got %d snapshots, want 3", len(received))
}
for i, s := range received {
if s.Source != "col-1" {
t.Errorf("[%d] source = %q", i, s.Source)
}
}
}
// --- Format helpers ---
func TestTargetHeader(t *testing.T) {
// Single target: no header.
if h := targetHeader("localhost:9090", "col-1", 1); h != "" {
t.Errorf("single-target header should be empty, got %q", h)
}
// Multi-target with distinct source name.
h := targetHeader("localhost:9090", "col-1", 2)
if !strings.Contains(h, "col-1") || !strings.Contains(h, "localhost:9090") {
t.Errorf("multi-target header = %q", h)
}
// Multi-target where source equals addr.
h2 := targetHeader("localhost:9090", "localhost:9090", 2)
if !strings.Contains(h2, "localhost:9090") {
t.Errorf("addr==source header = %q", h2)
}
}

15
cmd/cli/client.go Normal file
View File

@@ -0,0 +1,15 @@
package main
import (
pb "git.ipng.ch/ipng/nginx-logtail/proto/logtailpb"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
func dial(addr string) (*grpc.ClientConn, pb.LogtailServiceClient, error) {
conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
return nil, nil, err
}
return conn, pb.NewLogtailServiceClient(conn), nil
}

146
cmd/cli/cmd_stream.go Normal file
View File

@@ -0,0 +1,146 @@
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"io"
"log"
"os"
"os/signal"
"syscall"
"time"
pb "git.ipng.ch/ipng/nginx-logtail/proto/logtailpb"
)
type streamEvent struct {
target string
snap *pb.Snapshot
err error // non-nil means the stream for this target died
}
func runStream(args []string) {
fs := flag.NewFlagSet("stream", flag.ExitOnError)
sf, targetFlag := bindShared(fs)
fs.Parse(args)
sf.resolve(*targetFlag)
filter := buildFilter(sf)
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
events := make(chan streamEvent, 64)
for _, t := range sf.targets {
go streamTarget(ctx, t, filter, events)
}
nTargets := len(sf.targets)
for {
select {
case <-ctx.Done():
return
case ev := <-events:
if ev.err != nil {
if ev.err != io.EOF && ctx.Err() == nil {
fmt.Fprintf(os.Stderr, "stream %s: %v\n", ev.target, ev.err)
}
continue
}
if sf.jsonOut {
printStreamJSON(ev)
} else {
printStreamLine(ev, nTargets)
}
}
}
}
// streamTarget connects to addr and forwards received snapshots to events.
// On error it reconnects with a 5 s backoff until ctx is cancelled.
func streamTarget(ctx context.Context, addr string, filter *pb.Filter, events chan<- streamEvent) {
for {
if ctx.Err() != nil {
return
}
err := streamOnce(ctx, addr, filter, events)
if ctx.Err() != nil {
return
}
if err != nil {
log.Printf("stream %s: %v — reconnecting in 5s", addr, err)
select {
case <-ctx.Done():
return
case <-time.After(5 * time.Second):
}
}
}
}
func streamOnce(ctx context.Context, addr string, filter *pb.Filter, events chan<- streamEvent) error {
conn, client, err := dial(addr)
if err != nil {
return err
}
defer conn.Close()
stream, err := client.StreamSnapshots(ctx, &pb.SnapshotRequest{})
if err != nil {
return err
}
for {
snap, err := stream.Recv()
if err != nil {
return err
}
select {
case events <- streamEvent{target: addr, snap: snap}:
case <-ctx.Done():
return nil
}
}
}
func printStreamLine(ev streamEvent, nTargets int) {
ts := fmtTime(ev.snap.Timestamp)
entries := len(ev.snap.Entries)
topLabel := ""
var topCount int64
if entries > 0 {
topLabel = ev.snap.Entries[0].Label
topCount = ev.snap.Entries[0].Count
}
if nTargets > 1 {
src := ev.snap.Source
if src == "" {
src = ev.target
}
fmt.Printf("%s %-24s %5d entries top: %s=%s\n",
ts, src, entries, topLabel, fmtCount(topCount))
} else {
fmt.Printf("%s %5d entries top: %s=%s\n",
ts, entries, topLabel, fmtCount(topCount))
}
}
func printStreamJSON(ev streamEvent) {
topLabel := ""
var topCount int64
if len(ev.snap.Entries) > 0 {
topLabel = ev.snap.Entries[0].Label
topCount = ev.snap.Entries[0].Count
}
obj := map[string]any{
"ts": ev.snap.Timestamp,
"source": ev.snap.Source,
"target": ev.target,
"total_entries": len(ev.snap.Entries),
"top_label": topLabel,
"top_count": topCount,
}
b, _ := json.Marshal(obj)
fmt.Println(string(b))
}

122
cmd/cli/cmd_topn.go Normal file
View File

@@ -0,0 +1,122 @@
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"os"
"sync"
"time"
pb "git.ipng.ch/ipng/nginx-logtail/proto/logtailpb"
)
type topNResult struct {
target string
resp *pb.TopNResponse
err error
}
func runTopN(args []string) {
fs := flag.NewFlagSet("topn", flag.ExitOnError)
sf, targetFlag := bindShared(fs)
n := fs.Int("n", 10, "number of entries")
window := fs.String("window", "5m", "time window: 1m 5m 15m 60m 6h 24h")
groupBy := fs.String("group-by", "website", "group by: website prefix uri status")
fs.Parse(args)
sf.resolve(*targetFlag)
win := parseWindow(*window)
grp := parseGroupBy(*groupBy)
filter := buildFilter(sf)
results := fanOutTopN(sf.targets, filter, grp, *n, win)
for _, r := range results {
if hdr := targetHeader(r.target, r.resp.GetSource(), len(sf.targets)); hdr != "" {
fmt.Println(hdr)
}
if r.err != nil {
fmt.Fprintf(os.Stderr, "error from %s: %v\n", r.target, r.err)
continue
}
if sf.jsonOut {
printTopNJSON(r)
} else {
printTopNTable(r)
}
if len(sf.targets) > 1 {
fmt.Println()
}
}
}
func fanOutTopN(targets []string, filter *pb.Filter, groupBy pb.GroupBy, n int, window pb.Window) []topNResult {
results := make([]topNResult, len(targets))
var wg sync.WaitGroup
for i, t := range targets {
wg.Add(1)
go func(i int, addr string) {
defer wg.Done()
results[i].target = addr
conn, client, err := dial(addr)
if err != nil {
results[i].err = err
results[i].resp = &pb.TopNResponse{}
return
}
defer conn.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
resp, err := client.TopN(ctx, &pb.TopNRequest{
Filter: filter,
GroupBy: groupBy,
N: int32(n),
Window: window,
})
results[i].resp = resp
results[i].err = err
}(i, t)
}
wg.Wait()
return results
}
func printTopNTable(r topNResult) {
if len(r.resp.Entries) == 0 {
fmt.Println("(no data)")
return
}
rows := [][]string{{"RANK", "COUNT", "LABEL"}}
for i, e := range r.resp.Entries {
rows = append(rows, []string{
fmt.Sprintf("%4d", i+1),
fmtCount(e.Count),
e.Label,
})
}
printTable(os.Stdout, rows)
}
func printTopNJSON(r topNResult) {
type entry struct {
Label string `json:"label"`
Count int64 `json:"count"`
}
type out struct {
Source string `json:"source"`
Target string `json:"target"`
Entries []entry `json:"entries"`
}
o := out{
Source: r.resp.Source,
Target: r.target,
Entries: make([]entry, len(r.resp.Entries)),
}
for i, e := range r.resp.Entries {
o.Entries[i] = entry{Label: e.Label, Count: e.Count}
}
b, _ := json.Marshal(o)
fmt.Println(string(b))
}

113
cmd/cli/cmd_trend.go Normal file
View File

@@ -0,0 +1,113 @@
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"os"
"sync"
"time"
pb "git.ipng.ch/ipng/nginx-logtail/proto/logtailpb"
)
type trendResult struct {
target string
resp *pb.TrendResponse
err error
}
func runTrend(args []string) {
fs := flag.NewFlagSet("trend", flag.ExitOnError)
sf, targetFlag := bindShared(fs)
window := fs.String("window", "5m", "time window: 1m 5m 15m 60m 6h 24h")
fs.Parse(args)
sf.resolve(*targetFlag)
win := parseWindow(*window)
filter := buildFilter(sf)
results := fanOutTrend(sf.targets, filter, win)
for _, r := range results {
if hdr := targetHeader(r.target, r.resp.GetSource(), len(sf.targets)); hdr != "" {
fmt.Println(hdr)
}
if r.err != nil {
fmt.Fprintf(os.Stderr, "error from %s: %v\n", r.target, r.err)
continue
}
if sf.jsonOut {
printTrendJSON(r)
} else {
printTrendTable(r)
}
if len(sf.targets) > 1 {
fmt.Println()
}
}
}
func fanOutTrend(targets []string, filter *pb.Filter, window pb.Window) []trendResult {
results := make([]trendResult, len(targets))
var wg sync.WaitGroup
for i, t := range targets {
wg.Add(1)
go func(i int, addr string) {
defer wg.Done()
results[i].target = addr
conn, client, err := dial(addr)
if err != nil {
results[i].err = err
results[i].resp = &pb.TrendResponse{}
return
}
defer conn.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
resp, err := client.Trend(ctx, &pb.TrendRequest{
Filter: filter,
Window: window,
})
results[i].resp = resp
results[i].err = err
}(i, t)
}
wg.Wait()
return results
}
func printTrendTable(r trendResult) {
if len(r.resp.Points) == 0 {
fmt.Println("(no data)")
return
}
rows := [][]string{{"TIME (UTC)", "COUNT"}}
for _, p := range r.resp.Points {
rows = append(rows, []string{fmtTime(p.TimestampUnix), fmtCount(p.Count)})
}
printTable(os.Stdout, rows)
}
func printTrendJSON(r trendResult) {
type point struct {
Ts int64 `json:"ts"`
Count int64 `json:"count"`
}
type out struct {
Source string `json:"source"`
Target string `json:"target"`
Points []point `json:"points"`
}
o := out{
Source: r.resp.Source,
Target: r.target,
Points: make([]point, len(r.resp.Points)),
}
for i, p := range r.resp.Points {
o.Points[i] = point{Ts: p.TimestampUnix, Count: p.Count}
}
b, _ := json.Marshal(o)
fmt.Println(string(b))
}

122
cmd/cli/flags.go Normal file
View File

@@ -0,0 +1,122 @@
package main
import (
"flag"
"fmt"
"os"
"strconv"
"strings"
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"
}
// bindShared registers the shared flags on fs and returns a pointer to the
// populated struct. Call fs.Parse before reading the struct.
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)")
return sf, target
}
func (sf *sharedFlags) resolve(target string) {
sf.targets = parseTargets(target)
}
func parseTargets(s string) []string {
seen := make(map[string]bool)
var out []string
for _, t := range strings.Split(s, ",") {
t = strings.TrimSpace(t)
if t == "" || seen[t] {
continue
}
seen[t] = true
out = append(out, t)
}
return out
}
func buildFilter(sf *sharedFlags) *pb.Filter {
if sf.website == "" && sf.prefix == "" && sf.uri == "" && sf.status == "" {
return nil
}
f := &pb.Filter{}
if sf.website != "" {
f.Website = &sf.website
}
if sf.prefix != "" {
f.ClientPrefix = &sf.prefix
}
if sf.uri != "" {
f.HttpRequestUri = &sf.uri
}
if sf.status != "" {
n, err := strconv.Atoi(sf.status)
if err != nil {
fmt.Fprintf(os.Stderr, "--status: %v\n", err)
os.Exit(1)
}
n32 := int32(n)
f.HttpResponse = &n32
}
return f
}
func parseWindow(s string) pb.Window {
switch s {
case "1m":
return pb.Window_W1M
case "5m":
return pb.Window_W5M
case "15m":
return pb.Window_W15M
case "60m":
return pb.Window_W60M
case "6h":
return pb.Window_W6H
case "24h":
return pb.Window_W24H
default:
fmt.Fprintf(os.Stderr, "--window: unknown value %q; valid: 1m 5m 15m 60m 6h 24h\n", s)
os.Exit(1)
panic("unreachable")
}
}
func parseGroupBy(s string) pb.GroupBy {
switch s {
case "website":
return pb.GroupBy_WEBSITE
case "prefix":
return pb.GroupBy_CLIENT_PREFIX
case "uri":
return pb.GroupBy_REQUEST_URI
case "status":
return pb.GroupBy_HTTP_RESPONSE
default:
fmt.Fprintf(os.Stderr, "--group-by: unknown value %q; valid: website prefix uri status\n", s)
os.Exit(1)
panic("unreachable")
}
}
func dieUsage(fs *flag.FlagSet, msg string) {
fmt.Fprintln(os.Stderr, msg)
fs.PrintDefaults()
os.Exit(1)
}

68
cmd/cli/format.go Normal file
View File

@@ -0,0 +1,68 @@
package main
import (
"fmt"
"io"
"strings"
"text/tabwriter"
"time"
)
// printTable writes a formatted table with tabwriter. The first row is treated
// as the header and separated from data rows by a rule of dashes.
func printTable(w io.Writer, rows [][]string) {
if len(rows) == 0 {
return
}
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
for i, row := range rows {
fmt.Fprintln(tw, strings.Join(row, "\t"))
if i == 0 {
// Print a divider matching the header width.
dashes := make([]string, len(row))
for j, h := range row {
dashes[j] = strings.Repeat("-", len(h))
}
fmt.Fprintln(tw, strings.Join(dashes, "\t"))
}
}
tw.Flush()
}
// fmtCount formats a count with a space as the thousands separator.
// e.g. 1234567 → "1 234 567"
func fmtCount(n int64) string {
s := fmt.Sprintf("%d", n)
if len(s) <= 3 {
return s
}
var b strings.Builder
start := len(s) % 3
if start > 0 {
b.WriteString(s[:start])
}
for i := start; i < len(s); i += 3 {
if i > 0 {
b.WriteByte(' ')
}
b.WriteString(s[i : i+3])
}
return b.String()
}
// fmtTime formats a unix timestamp as "2006-01-02 15:04" UTC.
func fmtTime(unix int64) string {
return time.Unix(unix, 0).UTC().Format("2006-01-02 15:04")
}
// targetHeader returns the header line to print before each target's results.
// Returns empty string when there is only one target (clean single-target output).
func targetHeader(target, source string, nTargets int) string {
if nTargets <= 1 {
return ""
}
if source != "" && source != target {
return fmt.Sprintf("=== %s (%s) ===", source, target)
}
return fmt.Sprintf("=== %s ===", target)
}

50
cmd/cli/main.go Normal file
View File

@@ -0,0 +1,50 @@
package main
import (
"fmt"
"os"
)
const usage = `logtail-cli — debug shell for nginx-logtail collectors and aggregators
Usage:
logtail-cli topn [flags] ranked label → count list
logtail-cli trend [flags] per-minute time series
logtail-cli stream [flags] live snapshot feed
Subcommand flags (all subcommands):
--target host:port[,host:port,...] endpoints to query (default: localhost:9090)
--json emit newline-delimited JSON
--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
topn flags:
--n INT number of entries (default 10)
--window STR 1m 5m 15m 60m 6h 24h (default 5m)
--group-by STR website prefix uri status (default website)
trend flags:
--window STR 1m 5m 15m 60m 6h 24h (default 5m)
`
func main() {
if len(os.Args) < 2 {
fmt.Fprint(os.Stderr, usage)
os.Exit(1)
}
switch os.Args[1] {
case "topn":
runTopN(os.Args[2:])
case "trend":
runTrend(os.Args[2:])
case "stream":
runStream(os.Args[2:])
case "-h", "--help", "help":
fmt.Print(usage)
default:
fmt.Fprintf(os.Stderr, "unknown subcommand %q\n\n%s", os.Args[1], usage)
os.Exit(1)
}
}