Implement target selection, autodiscovery via aggregator, implement listTargets

This commit is contained in:
2026-03-15 05:04:46 +01:00
parent afa65a2b29
commit 7f93466645
16 changed files with 507 additions and 57 deletions

View File

@@ -32,7 +32,10 @@ DESIGN
``` ```
nginx-logtail/ nginx-logtail/
├── proto/ ├── proto/
── logtail.proto # shared protobuf definitions ── logtail.proto # shared protobuf definitions
│ └── logtailpb/
│ ├── logtail.pb.go # generated: messages, enums
│ └── logtail_grpc.pb.go # generated: service stubs
├── internal/ ├── internal/
│ └── store/ │ └── store/
│ └── store.go # shared types: Tuple4, Entry, Snapshot, ring helpers │ └── store.go # shared types: Tuple4, Entry, Snapshot, ring helpers
@@ -48,6 +51,7 @@ nginx-logtail/
│ ├── subscriber.go # one goroutine per collector; StreamSnapshots with backoff │ ├── subscriber.go # one goroutine per collector; StreamSnapshots with backoff
│ ├── merger.go # delta-merge: O(snapshot_size) per update │ ├── merger.go # delta-merge: O(snapshot_size) per update
│ ├── cache.go # tick-based ring buffer cache served to clients │ ├── cache.go # tick-based ring buffer cache served to clients
│ ├── registry.go # TargetRegistry: addr→name map updated from snapshot sources
│ └── server.go # gRPC server (same surface as collector) │ └── server.go # gRPC server (same surface as collector)
├── frontend/ ├── frontend/
│ ├── main.go │ ├── main.go
@@ -66,7 +70,8 @@ nginx-logtail/
├── format.go # printTable, fmtCount, fmtTime, targetHeader ├── format.go # printTable, fmtCount, fmtTime, targetHeader
├── cmd_topn.go # topn: concurrent fan-out, table + JSON output ├── cmd_topn.go # topn: concurrent fan-out, table + JSON output
├── cmd_trend.go # trend: concurrent fan-out, table + JSON output ├── cmd_trend.go # trend: concurrent fan-out, table + JSON output
── cmd_stream.go # stream: multiplexed streams, auto-reconnect ── cmd_stream.go # stream: multiplexed streams, auto-reconnect
└── cmd_targets.go # targets: list collectors known to the endpoint
``` ```
## Data Model ## Data Model
@@ -178,13 +183,23 @@ message Snapshot {
repeated TopNEntry entries = 3; // full top-50K for this bucket repeated TopNEntry entries = 3; // full top-50K for this bucket
} }
// Target discovery: list the collectors behind the queried endpoint
message ListTargetsRequest {}
message TargetInfo {
string name = 1; // display name (--source value from the collector)
string addr = 2; // gRPC address; empty string means "this endpoint itself"
}
message ListTargetsResponse { repeated TargetInfo targets = 1; }
service LogtailService { service LogtailService {
rpc TopN(TopNRequest) returns (TopNResponse); rpc TopN(TopNRequest) returns (TopNResponse);
rpc Trend(TrendRequest) returns (TrendResponse); rpc Trend(TrendRequest) returns (TrendResponse);
rpc StreamSnapshots(SnapshotRequest) returns (stream Snapshot); rpc StreamSnapshots(SnapshotRequest) returns (stream Snapshot);
rpc ListTargets(ListTargetsRequest) returns (ListTargetsResponse);
} }
// Both collector and aggregator implement LogtailService. // Both collector and aggregator implement LogtailService.
// The aggregator's StreamSnapshots re-streams the merged view. // The aggregator's StreamSnapshots re-streams the merged view.
// ListTargets: aggregator returns all configured collectors; collector returns itself.
``` ```
## Program 1 — Collector ## Program 1 — Collector
@@ -259,10 +274,18 @@ service LogtailService {
- Same tiered ring structure as the collector store; populated from `merger.TopK()` each tick. - Same tiered ring structure as the collector store; populated from `merger.TopK()` each tick.
- `QueryTopN`, `QueryTrend`, `Subscribe`/`Unsubscribe` — identical interface to collector store. - `QueryTopN`, `QueryTrend`, `Subscribe`/`Unsubscribe` — identical interface to collector store.
### registry.go
- **`TargetRegistry`**: `sync.RWMutex`-protected `map[addr → name]`. Initialised with the
configured collector addresses; display names are updated from the `source` field of the first
snapshot received from each collector.
- `Targets()` returns a stable sorted slice of `{name, addr}` pairs for `ListTargets` responses.
### server.go ### server.go
- Implements `LogtailService` backed by the cache (not live fan-out). - Implements `LogtailService` backed by the cache (not live fan-out).
- `StreamSnapshots` re-streams merged fine snapshots; usable by a second-tier aggregator or - `StreamSnapshots` re-streams merged fine snapshots; usable by a second-tier aggregator or
monitoring system. monitoring system.
- `ListTargets` returns the current `TargetRegistry` contents — all configured collectors with
their display names and gRPC addresses.
## Program 3 — Frontend ## Program 3 — Frontend
@@ -278,8 +301,13 @@ service LogtailService {
`store.ParseStatusExpr` into `(value, StatusOp)` for the filter protobuf. `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 - **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. 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 - `TopN`, `Trend`, and `ListTargets` RPCs issued **concurrently** (all with a 5 s deadline); page
whatever completes. Trend failure suppresses the sparkline without erroring the page. renders with whatever completes. Trend failure suppresses the sparkline; `ListTargets` failure
hides the source picker — both are non-fatal.
- **Source picker**: `ListTargets` result drives a `source:` tab row. Clicking a collector tab
sets `target=` to that collector's address, querying it directly. The "all" tab resets to the
default aggregator. Picker is hidden when `ListTargets` returns ≤0 collectors (direct collector
mode).
- **Drilldown**: clicking a table row adds the current dimension's filter and advances `by` through - **Drilldown**: clicking a table row adds the current dimension's filter and advances `by` through
`website → prefix → uri → status → website` (cycles). `website → prefix → uri → status → website` (cycles).
- **`raw=1`**: returns the TopN result as JSON — same URL, no CLI needed for scripting. - **`raw=1`**: returns the TopN result as JSON — same URL, no CLI needed for scripting.
@@ -305,6 +333,7 @@ service LogtailService {
logtail-cli topn [flags] ranked label → count table (exits after one response) logtail-cli topn [flags] ranked label → count table (exits after one response)
logtail-cli trend [flags] per-bucket time series (exits after one response) logtail-cli trend [flags] per-bucket time series (exits after one response)
logtail-cli stream [flags] live snapshot feed (runs until Ctrl-C, auto-reconnects) logtail-cli stream [flags] live snapshot feed (runs until Ctrl-C, auto-reconnects)
logtail-cli targets [flags] list targets known to the queried endpoint
``` ```
### Flags ### Flags
@@ -364,3 +393,4 @@ with a non-zero code on gRPC error.
| Status filter as expression string (`!=200`, `>=400`) | Operator-friendly; parsed once at query boundary, encoded as `(int32, StatusOp)` in proto | | 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 | | 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 | | Filter expression box (`q=`) redirects to canonical URL | Filter state stays in individual `f_*` params; URLs remain shareable and bookmarkable |
| `ListTargets` + frontend source picker (no Tuple5) | "Which nginx is busiest?" answered by switching `target=` to a collector; no data model changes, no extra memory |

View File

@@ -287,8 +287,8 @@ func TestGRPCEndToEnd(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
go NewCollectorSub(addr1, merger).Run(ctx) go NewCollectorSub(addr1, merger, NewTargetRegistry(nil)).Run(ctx)
go NewCollectorSub(addr2, merger).Run(ctx) go NewCollectorSub(addr2, merger, NewTargetRegistry(nil)).Run(ctx)
// Wait for both snapshots to be applied. // Wait for both snapshots to be applied.
deadline := time.Now().Add(3 * time.Second) deadline := time.Now().Add(3 * time.Second)
@@ -309,7 +309,7 @@ func TestGRPCEndToEnd(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
grpcSrv := grpc.NewServer() grpcSrv := grpc.NewServer()
pb.RegisterLogtailServiceServer(grpcSrv, NewServer(cache, "agg-test")) pb.RegisterLogtailServiceServer(grpcSrv, NewServer(cache, "agg-test", NewTargetRegistry(nil)))
go grpcSrv.Serve(lis) go grpcSrv.Serve(lis)
defer grpcSrv.Stop() defer grpcSrv.Stop()
@@ -399,8 +399,8 @@ func TestDegradedCollector(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
go NewCollectorSub(addr1, merger).Run(ctx) go NewCollectorSub(addr1, merger, NewTargetRegistry(nil)).Run(ctx)
go NewCollectorSub(addr2, merger).Run(ctx) go NewCollectorSub(addr2, merger, NewTargetRegistry(nil)).Run(ctx)
// Wait for col1's data to appear. // Wait for col1's data to appear.
deadline := time.Now().Add(3 * time.Second) deadline := time.Now().Add(3 * time.Second)

View File

@@ -27,16 +27,21 @@ func main() {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop() defer stop()
merger := NewMerger() var collectorAddrs []string
cache := NewCache(merger, *source)
go cache.Run(ctx)
for _, addr := range strings.Split(*collectors, ",") { for _, addr := range strings.Split(*collectors, ",") {
addr = strings.TrimSpace(addr) addr = strings.TrimSpace(addr)
if addr == "" { if addr != "" {
continue collectorAddrs = append(collectorAddrs, addr)
} }
sub := NewCollectorSub(addr, merger) }
merger := NewMerger()
cache := NewCache(merger, *source)
registry := NewTargetRegistry(collectorAddrs)
go cache.Run(ctx)
for _, addr := range collectorAddrs {
sub := NewCollectorSub(addr, merger, registry)
go sub.Run(ctx) go sub.Run(ctx)
log.Printf("aggregator: subscribing to collector %s", addr) log.Printf("aggregator: subscribing to collector %s", addr)
} }
@@ -46,7 +51,7 @@ func main() {
log.Fatalf("aggregator: failed to listen on %s: %v", *listen, err) log.Fatalf("aggregator: failed to listen on %s: %v", *listen, err)
} }
grpcServer := grpc.NewServer() grpcServer := grpc.NewServer()
pb.RegisterLogtailServiceServer(grpcServer, NewServer(cache, *source)) pb.RegisterLogtailServiceServer(grpcServer, NewServer(cache, *source, registry))
go func() { go func() {
log.Printf("aggregator: gRPC listening on %s (source=%s)", *listen, *source) log.Printf("aggregator: gRPC listening on %s (source=%s)", *listen, *source)

View File

@@ -0,0 +1,47 @@
package main
import (
"sort"
"sync"
)
// TargetInfo holds the display name and gRPC address of one collector target.
type TargetInfo struct {
Name string // collector --source value, falls back to addr until first snapshot
Addr string // configured gRPC address
}
// TargetRegistry tracks addr → display name for all configured collectors.
// Names default to the addr and are updated to the collector's --source value
// when the first snapshot arrives.
type TargetRegistry struct {
mu sync.RWMutex
names map[string]string // addr → current name
}
func NewTargetRegistry(addrs []string) *TargetRegistry {
names := make(map[string]string, len(addrs))
for _, a := range addrs {
names[a] = a // default until first snapshot
}
return &TargetRegistry{names: names}
}
// SetName updates the display name for addr (called when a snapshot arrives).
func (r *TargetRegistry) SetName(addr, name string) {
r.mu.Lock()
r.names[addr] = name
r.mu.Unlock()
}
// Targets returns all registered targets sorted by addr.
func (r *TargetRegistry) Targets() []TargetInfo {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]TargetInfo, 0, len(r.names))
for addr, name := range r.names {
out = append(out, TargetInfo{Name: name, Addr: addr})
}
sort.Slice(out, func(i, j int) bool { return out[i].Addr < out[j].Addr })
return out
}

View File

@@ -14,12 +14,13 @@ import (
// Server implements pb.LogtailServiceServer backed by the aggregator Cache. // Server implements pb.LogtailServiceServer backed by the aggregator Cache.
type Server struct { type Server struct {
pb.UnimplementedLogtailServiceServer pb.UnimplementedLogtailServiceServer
cache *Cache cache *Cache
source string source string
registry *TargetRegistry
} }
func NewServer(cache *Cache, source string) *Server { func NewServer(cache *Cache, source string, registry *TargetRegistry) *Server {
return &Server{cache: cache, source: source} return &Server{cache: cache, source: source, registry: registry}
} }
func (srv *Server) TopN(_ context.Context, req *pb.TopNRequest) (*pb.TopNResponse, error) { func (srv *Server) TopN(_ context.Context, req *pb.TopNRequest) (*pb.TopNResponse, error) {
@@ -53,6 +54,16 @@ func (srv *Server) Trend(_ context.Context, req *pb.TrendRequest) (*pb.TrendResp
return resp, nil return resp, nil
} }
func (srv *Server) ListTargets(_ context.Context, _ *pb.ListTargetsRequest) (*pb.ListTargetsResponse, error) {
resp := &pb.ListTargetsResponse{}
if srv.registry != nil {
for _, t := range srv.registry.Targets() {
resp.Targets = append(resp.Targets, &pb.TargetInfo{Name: t.Name, Addr: t.Addr})
}
}
return resp, nil
}
func (srv *Server) StreamSnapshots(_ *pb.SnapshotRequest, stream grpc.ServerStreamingServer[pb.Snapshot]) error { func (srv *Server) StreamSnapshots(_ *pb.SnapshotRequest, stream grpc.ServerStreamingServer[pb.Snapshot]) error {
ch := srv.cache.Subscribe() ch := srv.cache.Subscribe()
defer srv.cache.Unsubscribe(ch) defer srv.cache.Unsubscribe(ch)

View File

@@ -15,12 +15,13 @@ import (
// the collector degraded (zeroing its contribution) after 3 consecutive // the collector degraded (zeroing its contribution) after 3 consecutive
// failures. // failures.
type CollectorSub struct { type CollectorSub struct {
addr string addr string
merger *Merger merger *Merger
registry *TargetRegistry
} }
func NewCollectorSub(addr string, merger *Merger) *CollectorSub { func NewCollectorSub(addr string, merger *Merger, registry *TargetRegistry) *CollectorSub {
return &CollectorSub{addr: addr, merger: merger} return &CollectorSub{addr: addr, merger: merger, registry: registry}
} }
// Run blocks until ctx is cancelled. // Run blocks until ctx is cancelled.
@@ -92,6 +93,7 @@ func (cs *CollectorSub) stream(ctx context.Context) (bool, error) {
return gotOne, err return gotOne, err
} }
gotOne = true gotOne = true
cs.registry.SetName(cs.addr, snap.Source)
cs.merger.Apply(snap) cs.merger.Apply(snap)
} }
} }

61
cmd/cli/cmd_targets.go Normal file
View File

@@ -0,0 +1,61 @@
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"os"
"time"
pb "git.ipng.ch/ipng/nginx-logtail/proto/logtailpb"
)
func runTargets(args []string) {
fs := flag.NewFlagSet("targets", flag.ExitOnError)
sf, target := bindShared(fs)
fs.Usage = func() {
fmt.Fprintln(os.Stderr, "usage: logtail-cli targets [--target host:port] [--json]")
fs.PrintDefaults()
}
fs.Parse(args)
sf.resolve(*target)
for _, addr := range sf.targets {
conn, client, err := dial(addr)
if err != nil {
fmt.Fprintf(os.Stderr, "targets: cannot connect to %s: %v\n", addr, err)
continue
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
resp, err := client.ListTargets(ctx, &pb.ListTargetsRequest{})
cancel()
conn.Close()
if err != nil {
fmt.Fprintf(os.Stderr, "targets: %s: %v\n", addr, err)
continue
}
if sf.jsonOut {
type row struct {
QueryTarget string `json:"query_target"`
Name string `json:"name"`
Addr string `json:"addr"`
}
for _, t := range resp.Targets {
json.NewEncoder(os.Stdout).Encode(row{QueryTarget: addr, Name: t.Name, Addr: t.Addr})
}
} else {
if len(sf.targets) > 1 {
fmt.Println(targetHeader(addr, "", len(sf.targets)))
}
for _, t := range resp.Targets {
addrCol := t.Addr
if addrCol == "" {
addrCol = "(self)"
}
fmt.Printf("%-40s %s\n", t.Name, addrCol)
}
}
}
}

View File

@@ -11,6 +11,7 @@ Usage:
logtail-cli topn [flags] ranked label → count list logtail-cli topn [flags] ranked label → count list
logtail-cli trend [flags] per-minute time series logtail-cli trend [flags] per-minute time series
logtail-cli stream [flags] live snapshot feed logtail-cli stream [flags] live snapshot feed
logtail-cli targets [flags] list targets known to the queried endpoint
Subcommand flags (all subcommands): Subcommand flags (all subcommands):
--target host:port[,host:port,...] endpoints to query (default: localhost:9090) --target host:port[,host:port,...] endpoints to query (default: localhost:9090)
@@ -43,6 +44,8 @@ func main() {
runTrend(os.Args[2:]) runTrend(os.Args[2:])
case "stream": case "stream":
runStream(os.Args[2:]) runStream(os.Args[2:])
case "targets":
runTargets(os.Args[2:])
case "-h", "--help", "help": case "-h", "--help", "help":
fmt.Print(usage) fmt.Print(usage)
default: default:

View File

@@ -56,6 +56,12 @@ func (srv *Server) Trend(_ context.Context, req *pb.TrendRequest) (*pb.TrendResp
return resp, nil return resp, nil
} }
func (srv *Server) ListTargets(_ context.Context, _ *pb.ListTargetsRequest) (*pb.ListTargetsResponse, error) {
return &pb.ListTargetsResponse{
Targets: []*pb.TargetInfo{{Name: srv.source, Addr: ""}},
}, nil
}
func (srv *Server) StreamSnapshots(req *pb.SnapshotRequest, stream grpc.ServerStreamingServer[pb.Snapshot]) error { func (srv *Server) StreamSnapshots(req *pb.SnapshotRequest, stream grpc.ServerStreamingServer[pb.Snapshot]) error {
ch := srv.store.Subscribe() ch := srv.store.Subscribe()
defer srv.store.Unsubscribe(ch) defer srv.store.Unsubscribe(ch)

View File

@@ -76,6 +76,7 @@ type PageData struct {
Breadcrumbs []Crumb Breadcrumbs []Crumb
Windows []Tab Windows []Tab
GroupBys []Tab GroupBys []Tab
Targets []Tab // source/target picker; empty when only one target available
RefreshSecs int RefreshSecs int
Error string Error string
FilterExpr string // current filter serialised to mini-language for the input box FilterExpr string // current filter serialised to mini-language for the input box
@@ -340,6 +341,38 @@ func buildGroupByTabs(p QueryParams) []Tab {
return tabs return tabs
} }
// buildTargetTabs builds the source/target picker tabs from a ListTargets response.
// Returns nil (hide picker) when only one endpoint is reachable.
func (h *Handler) buildTargetTabs(p QueryParams, lt *pb.ListTargetsResponse) []Tab {
// "all" always points at the configured aggregator default.
allTab := Tab{
Label: "all",
URL: p.buildURL(map[string]string{"target": h.defaultTarget}),
Active: p.Target == h.defaultTarget,
}
var collectorTabs []Tab
if lt != nil {
for _, t := range lt.Targets {
addr := t.Addr
if addr == "" {
addr = p.Target // collector reporting itself; addr is the current target
}
collectorTabs = append(collectorTabs, Tab{
Label: t.Name,
URL: p.buildURL(map[string]string{"target": addr}),
Active: p.Target == addr,
})
}
}
// Only render the picker when there is more than one choice.
if len(collectorTabs) == 0 {
return nil
}
return append([]Tab{allTab}, collectorTabs...)
}
func buildTableRows(entries []*pb.TopNEntry, p QueryParams) ([]TableRow, int64) { func buildTableRows(entries []*pb.TopNEntry, p QueryParams) ([]TableRow, int64) {
if len(entries) == 0 { if len(entries) == 0 {
return nil, 0 return nil, 0
@@ -410,6 +443,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
} }
topNCh := make(chan topNResult, 1) topNCh := make(chan topNResult, 1)
trendCh := make(chan trendResult, 1) trendCh := make(chan trendResult, 1)
ltCh := make(chan *pb.ListTargetsResponse, 1)
go func() { go func() {
resp, err := client.TopN(ctx, &pb.TopNRequest{ resp, err := client.TopN(ctx, &pb.TopNRequest{
@@ -427,9 +461,18 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}) })
trendCh <- trendResult{resp, err} trendCh <- trendResult{resp, err}
}() }()
go func() {
resp, err := client.ListTargets(ctx, &pb.ListTargetsRequest{})
if err != nil {
ltCh <- nil
} else {
ltCh <- resp
}
}()
tn := <-topNCh tn := <-topNCh
tr := <-trendCh tr := <-trendCh
lt := <-ltCh
if tn.err != nil { if tn.err != nil {
h.render(w, http.StatusBadGateway, h.errorPage(params, h.render(w, http.StatusBadGateway, h.errorPage(params,
@@ -459,6 +502,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
Breadcrumbs: buildCrumbs(params), Breadcrumbs: buildCrumbs(params),
Windows: buildWindowTabs(params), Windows: buildWindowTabs(params),
GroupBys: buildGroupByTabs(params), GroupBys: buildGroupByTabs(params),
Targets: h.buildTargetTabs(params, lt),
RefreshSecs: h.refreshSecs, RefreshSecs: h.refreshSecs,
FilterExpr: filterExprInput, FilterExpr: filterExprInput,
FilterErr: filterErr, FilterErr: filterErr,

View File

@@ -34,6 +34,8 @@ a:hover { text-decoration: underline; }
.error { color: #c00; border: 1px solid #fbb; background: #fff5f5; padding: 0.7em 1em; margin: 1em 0; border-radius: 3px; } .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; } .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; } footer { margin-top: 2em; padding-top: 0.6em; border-top: 1px solid #e0e0e0; font-size: 0.8em; color: #999; }
.tabs-targets { margin-top: -0.4em; }
.tabs-label { font-size: 0.85em; color: #888; margin-right: 0.2em; align-self: center; }
.filter-form { display: flex; gap: 0.4em; align-items: center; margin-bottom: 0.7em; } .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-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 { padding: 0.25em 0.8em; border: 1px solid #aaa; background: #f4f4f4; cursor: pointer; font-family: monospace; }

View File

@@ -13,6 +13,13 @@
{{- end}} {{- end}}
</div> </div>
{{if .Targets}}<div class="tabs tabs-targets">
<span class="tabs-label">source:</span>
{{- range .Targets}}
<a href="{{.URL}}"{{if .Active}} class="active"{{end}}>{{.Label}}</a>
{{- end}}
</div>{{end}}
<form class="filter-form" method="get" action="/"> <form class="filter-form" method="get" action="/">
<input type="hidden" name="target" value="{{.Params.Target}}"> <input type="hidden" name="target" value="{{.Params.Target}}">
<input type="hidden" name="w" value="{{.Params.WindowS}}"> <input type="hidden" name="w" value="{{.Params.WindowS}}">

View File

@@ -324,6 +324,12 @@ endpoint for that request (useful for comparing a single collector against the a
http://frontend:8080/?target=nginx3:9090&w=5m http://frontend:8080/?target=nginx3:9090&w=5m
``` ```
**Source picker** — when the frontend is pointed at an aggregator, a `source:` tab row appears
below the dimension tabs listing each individual collector alongside an **all** tab (the default
merged view). Clicking a collector tab switches the frontend to query that collector directly for
the current request, letting you answer "which nginx machine is the busiest?" without leaving the
dashboard. The picker is hidden when querying a collector directly (it has no sub-sources to list).
--- ---
## CLI ## CLI
@@ -338,6 +344,7 @@ Default output is a human-readable table; add `--json` for machine-readable NDJS
logtail-cli topn [flags] ranked label → count table logtail-cli topn [flags] ranked label → count table
logtail-cli trend [flags] per-bucket time series logtail-cli trend [flags] per-bucket time series
logtail-cli stream [flags] live snapshot feed (runs until Ctrl-C) logtail-cli stream [flags] live snapshot feed (runs until Ctrl-C)
logtail-cli targets [flags] list targets known to the queried endpoint
``` ```
### Shared flags (all subcommands) ### Shared flags (all subcommands)
@@ -397,6 +404,32 @@ RANK COUNT LABEL
{"ts":1773516180,"source":"col-1","target":"nginx1:9090","total_entries":823,"top_label":"example.com","top_count":10000} {"ts":1773516180,"source":"col-1","target":"nginx1:9090","total_entries":823,"top_label":"example.com","top_count":10000}
``` ```
### `targets` subcommand
Lists the targets (collectors) known to the queried endpoint. When querying an aggregator, returns
all configured collectors with their display names and addresses. When querying a collector,
returns the collector itself (address shown as `(self)`).
```bash
# List collectors behind the aggregator
logtail-cli targets --target agg:9091
# Machine-readable output
logtail-cli targets --target agg:9091 --json
```
Table output example:
```
nginx1.prod nginx1:9090
nginx2.prod nginx2:9090
nginx3.prod (self)
```
JSON output (`--json`) — one object per target:
```json
{"query_target":"agg:9091","name":"nginx1.prod","addr":"nginx1:9090"}
```
### Examples ### Examples
```bash ```bash

View File

@@ -89,8 +89,23 @@ message Snapshot {
repeated TopNEntry entries = 3; // top-50K for this 1-minute bucket, sorted desc repeated TopNEntry entries = 3; // top-50K for this 1-minute bucket, sorted desc
} }
service LogtailService { // ListTargets — returns the targets this node knows about.
rpc TopN (TopNRequest) returns (TopNResponse); // The aggregator returns all configured collectors; a collector returns itself.
rpc Trend (TrendRequest) returns (TrendResponse);
rpc StreamSnapshots (SnapshotRequest) returns (stream Snapshot); message ListTargetsRequest {}
message TargetInfo {
string name = 1; // display name (the --source value of the collector)
string addr = 2; // gRPC address to use as target=; empty means "this endpoint"
}
message ListTargetsResponse {
repeated TargetInfo targets = 1;
}
service LogtailService {
rpc TopN (TopNRequest) returns (TopNResponse);
rpc Trend (TrendRequest) returns (TrendResponse);
rpc StreamSnapshots (SnapshotRequest) returns (stream Snapshot);
rpc ListTargets (ListTargetsRequest) returns (ListTargetsResponse);
} }

View File

@@ -709,6 +709,138 @@ func (x *Snapshot) GetEntries() []*TopNEntry {
return nil return nil
} }
type ListTargetsRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ListTargetsRequest) Reset() {
*x = ListTargetsRequest{}
mi := &file_logtail_proto_msgTypes[9]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ListTargetsRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ListTargetsRequest) ProtoMessage() {}
func (x *ListTargetsRequest) ProtoReflect() protoreflect.Message {
mi := &file_logtail_proto_msgTypes[9]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ListTargetsRequest.ProtoReflect.Descriptor instead.
func (*ListTargetsRequest) Descriptor() ([]byte, []int) {
return file_logtail_proto_rawDescGZIP(), []int{9}
}
type TargetInfo struct {
state protoimpl.MessageState `protogen:"open.v1"`
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` // display name (the --source value of the collector)
Addr string `protobuf:"bytes,2,opt,name=addr,proto3" json:"addr,omitempty"` // gRPC address to use as target=; empty means "this endpoint"
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *TargetInfo) Reset() {
*x = TargetInfo{}
mi := &file_logtail_proto_msgTypes[10]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *TargetInfo) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*TargetInfo) ProtoMessage() {}
func (x *TargetInfo) ProtoReflect() protoreflect.Message {
mi := &file_logtail_proto_msgTypes[10]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use TargetInfo.ProtoReflect.Descriptor instead.
func (*TargetInfo) Descriptor() ([]byte, []int) {
return file_logtail_proto_rawDescGZIP(), []int{10}
}
func (x *TargetInfo) GetName() string {
if x != nil {
return x.Name
}
return ""
}
func (x *TargetInfo) GetAddr() string {
if x != nil {
return x.Addr
}
return ""
}
type ListTargetsResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Targets []*TargetInfo `protobuf:"bytes,1,rep,name=targets,proto3" json:"targets,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ListTargetsResponse) Reset() {
*x = ListTargetsResponse{}
mi := &file_logtail_proto_msgTypes[11]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ListTargetsResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ListTargetsResponse) ProtoMessage() {}
func (x *ListTargetsResponse) ProtoReflect() protoreflect.Message {
mi := &file_logtail_proto_msgTypes[11]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ListTargetsResponse.ProtoReflect.Descriptor instead.
func (*ListTargetsResponse) Descriptor() ([]byte, []int) {
return file_logtail_proto_rawDescGZIP(), []int{11}
}
func (x *ListTargetsResponse) GetTargets() []*TargetInfo {
if x != nil {
return x.Targets
}
return nil
}
var File_logtail_proto protoreflect.FileDescriptor var File_logtail_proto protoreflect.FileDescriptor
const file_logtail_proto_rawDesc = "" + const file_logtail_proto_rawDesc = "" +
@@ -755,7 +887,14 @@ const file_logtail_proto_rawDesc = "" +
"\bSnapshot\x12\x16\n" + "\bSnapshot\x12\x16\n" +
"\x06source\x18\x01 \x01(\tR\x06source\x12\x1c\n" + "\x06source\x18\x01 \x01(\tR\x06source\x12\x1c\n" +
"\ttimestamp\x18\x02 \x01(\x03R\ttimestamp\x12,\n" + "\ttimestamp\x18\x02 \x01(\x03R\ttimestamp\x12,\n" +
"\aentries\x18\x03 \x03(\v2\x12.logtail.TopNEntryR\aentries*:\n" + "\aentries\x18\x03 \x03(\v2\x12.logtail.TopNEntryR\aentries\"\x14\n" +
"\x12ListTargetsRequest\"4\n" +
"\n" +
"TargetInfo\x12\x12\n" +
"\x04name\x18\x01 \x01(\tR\x04name\x12\x12\n" +
"\x04addr\x18\x02 \x01(\tR\x04addr\"D\n" +
"\x13ListTargetsResponse\x12-\n" +
"\atargets\x18\x01 \x03(\v2\x13.logtail.TargetInfoR\atargets*:\n" +
"\bStatusOp\x12\x06\n" + "\bStatusOp\x12\x06\n" +
"\x02EQ\x10\x00\x12\x06\n" + "\x02EQ\x10\x00\x12\x06\n" +
"\x02NE\x10\x01\x12\x06\n" + "\x02NE\x10\x01\x12\x06\n" +
@@ -774,11 +913,12 @@ const file_logtail_proto_rawDesc = "" +
"\x04W15M\x10\x02\x12\b\n" + "\x04W15M\x10\x02\x12\b\n" +
"\x04W60M\x10\x03\x12\a\n" + "\x04W60M\x10\x03\x12\a\n" +
"\x03W6H\x10\x04\x12\b\n" + "\x03W6H\x10\x04\x12\b\n" +
"\x04W24H\x10\x052\xbf\x01\n" + "\x04W24H\x10\x052\x89\x02\n" +
"\x0eLogtailService\x123\n" + "\x0eLogtailService\x123\n" +
"\x04TopN\x12\x14.logtail.TopNRequest\x1a\x15.logtail.TopNResponse\x126\n" + "\x04TopN\x12\x14.logtail.TopNRequest\x1a\x15.logtail.TopNResponse\x126\n" +
"\x05Trend\x12\x15.logtail.TrendRequest\x1a\x16.logtail.TrendResponse\x12@\n" + "\x05Trend\x12\x15.logtail.TrendRequest\x1a\x16.logtail.TrendResponse\x12@\n" +
"\x0fStreamSnapshots\x12\x18.logtail.SnapshotRequest\x1a\x11.logtail.Snapshot0\x01B0Z.git.ipng.ch/ipng/nginx-logtail/proto/logtailpbb\x06proto3" "\x0fStreamSnapshots\x12\x18.logtail.SnapshotRequest\x1a\x11.logtail.Snapshot0\x01\x12H\n" +
"\vListTargets\x12\x1b.logtail.ListTargetsRequest\x1a\x1c.logtail.ListTargetsResponseB0Z.git.ipng.ch/ipng/nginx-logtail/proto/logtailpbb\x06proto3"
var ( var (
file_logtail_proto_rawDescOnce sync.Once file_logtail_proto_rawDescOnce sync.Once
@@ -793,20 +933,23 @@ func file_logtail_proto_rawDescGZIP() []byte {
} }
var file_logtail_proto_enumTypes = make([]protoimpl.EnumInfo, 3) var file_logtail_proto_enumTypes = make([]protoimpl.EnumInfo, 3)
var file_logtail_proto_msgTypes = make([]protoimpl.MessageInfo, 9) var file_logtail_proto_msgTypes = make([]protoimpl.MessageInfo, 12)
var file_logtail_proto_goTypes = []any{ var file_logtail_proto_goTypes = []any{
(StatusOp)(0), // 0: logtail.StatusOp (StatusOp)(0), // 0: logtail.StatusOp
(GroupBy)(0), // 1: logtail.GroupBy (GroupBy)(0), // 1: logtail.GroupBy
(Window)(0), // 2: logtail.Window (Window)(0), // 2: logtail.Window
(*Filter)(nil), // 3: logtail.Filter (*Filter)(nil), // 3: logtail.Filter
(*TopNRequest)(nil), // 4: logtail.TopNRequest (*TopNRequest)(nil), // 4: logtail.TopNRequest
(*TopNEntry)(nil), // 5: logtail.TopNEntry (*TopNEntry)(nil), // 5: logtail.TopNEntry
(*TopNResponse)(nil), // 6: logtail.TopNResponse (*TopNResponse)(nil), // 6: logtail.TopNResponse
(*TrendRequest)(nil), // 7: logtail.TrendRequest (*TrendRequest)(nil), // 7: logtail.TrendRequest
(*TrendPoint)(nil), // 8: logtail.TrendPoint (*TrendPoint)(nil), // 8: logtail.TrendPoint
(*TrendResponse)(nil), // 9: logtail.TrendResponse (*TrendResponse)(nil), // 9: logtail.TrendResponse
(*SnapshotRequest)(nil), // 10: logtail.SnapshotRequest (*SnapshotRequest)(nil), // 10: logtail.SnapshotRequest
(*Snapshot)(nil), // 11: logtail.Snapshot (*Snapshot)(nil), // 11: logtail.Snapshot
(*ListTargetsRequest)(nil), // 12: logtail.ListTargetsRequest
(*TargetInfo)(nil), // 13: logtail.TargetInfo
(*ListTargetsResponse)(nil), // 14: logtail.ListTargetsResponse
} }
var file_logtail_proto_depIdxs = []int32{ var file_logtail_proto_depIdxs = []int32{
0, // 0: logtail.Filter.status_op:type_name -> logtail.StatusOp 0, // 0: logtail.Filter.status_op:type_name -> logtail.StatusOp
@@ -818,17 +961,20 @@ var file_logtail_proto_depIdxs = []int32{
2, // 6: logtail.TrendRequest.window:type_name -> logtail.Window 2, // 6: logtail.TrendRequest.window:type_name -> logtail.Window
8, // 7: logtail.TrendResponse.points:type_name -> logtail.TrendPoint 8, // 7: logtail.TrendResponse.points:type_name -> logtail.TrendPoint
5, // 8: logtail.Snapshot.entries:type_name -> logtail.TopNEntry 5, // 8: logtail.Snapshot.entries:type_name -> logtail.TopNEntry
4, // 9: logtail.LogtailService.TopN:input_type -> logtail.TopNRequest 13, // 9: logtail.ListTargetsResponse.targets:type_name -> logtail.TargetInfo
7, // 10: logtail.LogtailService.Trend:input_type -> logtail.TrendRequest 4, // 10: logtail.LogtailService.TopN:input_type -> logtail.TopNRequest
10, // 11: logtail.LogtailService.StreamSnapshots:input_type -> logtail.SnapshotRequest 7, // 11: logtail.LogtailService.Trend:input_type -> logtail.TrendRequest
6, // 12: logtail.LogtailService.TopN:output_type -> logtail.TopNResponse 10, // 12: logtail.LogtailService.StreamSnapshots:input_type -> logtail.SnapshotRequest
9, // 13: logtail.LogtailService.Trend:output_type -> logtail.TrendResponse 12, // 13: logtail.LogtailService.ListTargets:input_type -> logtail.ListTargetsRequest
11, // 14: logtail.LogtailService.StreamSnapshots:output_type -> logtail.Snapshot 6, // 14: logtail.LogtailService.TopN:output_type -> logtail.TopNResponse
12, // [12:15] is the sub-list for method output_type 9, // 15: logtail.LogtailService.Trend:output_type -> logtail.TrendResponse
9, // [9:12] is the sub-list for method input_type 11, // 16: logtail.LogtailService.StreamSnapshots:output_type -> logtail.Snapshot
9, // [9:9] is the sub-list for extension type_name 14, // 17: logtail.LogtailService.ListTargets:output_type -> logtail.ListTargetsResponse
9, // [9:9] is the sub-list for extension extendee 14, // [14:18] is the sub-list for method output_type
0, // [0:9] is the sub-list for field type_name 10, // [10:14] is the sub-list for method input_type
10, // [10:10] is the sub-list for extension type_name
10, // [10:10] is the sub-list for extension extendee
0, // [0:10] is the sub-list for field type_name
} }
func init() { file_logtail_proto_init() } func init() { file_logtail_proto_init() }
@@ -843,7 +989,7 @@ func file_logtail_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(), GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_logtail_proto_rawDesc), len(file_logtail_proto_rawDesc)), RawDescriptor: unsafe.Slice(unsafe.StringData(file_logtail_proto_rawDesc), len(file_logtail_proto_rawDesc)),
NumEnums: 3, NumEnums: 3,
NumMessages: 9, NumMessages: 12,
NumExtensions: 0, NumExtensions: 0,
NumServices: 1, NumServices: 1,
}, },

View File

@@ -22,6 +22,7 @@ const (
LogtailService_TopN_FullMethodName = "/logtail.LogtailService/TopN" LogtailService_TopN_FullMethodName = "/logtail.LogtailService/TopN"
LogtailService_Trend_FullMethodName = "/logtail.LogtailService/Trend" LogtailService_Trend_FullMethodName = "/logtail.LogtailService/Trend"
LogtailService_StreamSnapshots_FullMethodName = "/logtail.LogtailService/StreamSnapshots" LogtailService_StreamSnapshots_FullMethodName = "/logtail.LogtailService/StreamSnapshots"
LogtailService_ListTargets_FullMethodName = "/logtail.LogtailService/ListTargets"
) )
// LogtailServiceClient is the client API for LogtailService service. // LogtailServiceClient is the client API for LogtailService service.
@@ -31,6 +32,7 @@ type LogtailServiceClient interface {
TopN(ctx context.Context, in *TopNRequest, opts ...grpc.CallOption) (*TopNResponse, error) TopN(ctx context.Context, in *TopNRequest, opts ...grpc.CallOption) (*TopNResponse, error)
Trend(ctx context.Context, in *TrendRequest, opts ...grpc.CallOption) (*TrendResponse, error) Trend(ctx context.Context, in *TrendRequest, opts ...grpc.CallOption) (*TrendResponse, error)
StreamSnapshots(ctx context.Context, in *SnapshotRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[Snapshot], error) StreamSnapshots(ctx context.Context, in *SnapshotRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[Snapshot], error)
ListTargets(ctx context.Context, in *ListTargetsRequest, opts ...grpc.CallOption) (*ListTargetsResponse, error)
} }
type logtailServiceClient struct { type logtailServiceClient struct {
@@ -80,6 +82,16 @@ func (c *logtailServiceClient) StreamSnapshots(ctx context.Context, in *Snapshot
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. // This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type LogtailService_StreamSnapshotsClient = grpc.ServerStreamingClient[Snapshot] type LogtailService_StreamSnapshotsClient = grpc.ServerStreamingClient[Snapshot]
func (c *logtailServiceClient) ListTargets(ctx context.Context, in *ListTargetsRequest, opts ...grpc.CallOption) (*ListTargetsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListTargetsResponse)
err := c.cc.Invoke(ctx, LogtailService_ListTargets_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// LogtailServiceServer is the server API for LogtailService service. // LogtailServiceServer is the server API for LogtailService service.
// All implementations must embed UnimplementedLogtailServiceServer // All implementations must embed UnimplementedLogtailServiceServer
// for forward compatibility. // for forward compatibility.
@@ -87,6 +99,7 @@ type LogtailServiceServer interface {
TopN(context.Context, *TopNRequest) (*TopNResponse, error) TopN(context.Context, *TopNRequest) (*TopNResponse, error)
Trend(context.Context, *TrendRequest) (*TrendResponse, error) Trend(context.Context, *TrendRequest) (*TrendResponse, error)
StreamSnapshots(*SnapshotRequest, grpc.ServerStreamingServer[Snapshot]) error StreamSnapshots(*SnapshotRequest, grpc.ServerStreamingServer[Snapshot]) error
ListTargets(context.Context, *ListTargetsRequest) (*ListTargetsResponse, error)
mustEmbedUnimplementedLogtailServiceServer() mustEmbedUnimplementedLogtailServiceServer()
} }
@@ -106,6 +119,9 @@ func (UnimplementedLogtailServiceServer) Trend(context.Context, *TrendRequest) (
func (UnimplementedLogtailServiceServer) StreamSnapshots(*SnapshotRequest, grpc.ServerStreamingServer[Snapshot]) error { func (UnimplementedLogtailServiceServer) StreamSnapshots(*SnapshotRequest, grpc.ServerStreamingServer[Snapshot]) error {
return status.Error(codes.Unimplemented, "method StreamSnapshots not implemented") return status.Error(codes.Unimplemented, "method StreamSnapshots not implemented")
} }
func (UnimplementedLogtailServiceServer) ListTargets(context.Context, *ListTargetsRequest) (*ListTargetsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListTargets not implemented")
}
func (UnimplementedLogtailServiceServer) mustEmbedUnimplementedLogtailServiceServer() {} func (UnimplementedLogtailServiceServer) mustEmbedUnimplementedLogtailServiceServer() {}
func (UnimplementedLogtailServiceServer) testEmbeddedByValue() {} func (UnimplementedLogtailServiceServer) testEmbeddedByValue() {}
@@ -174,6 +190,24 @@ func _LogtailService_StreamSnapshots_Handler(srv interface{}, stream grpc.Server
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. // This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type LogtailService_StreamSnapshotsServer = grpc.ServerStreamingServer[Snapshot] type LogtailService_StreamSnapshotsServer = grpc.ServerStreamingServer[Snapshot]
func _LogtailService_ListTargets_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListTargetsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(LogtailServiceServer).ListTargets(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: LogtailService_ListTargets_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(LogtailServiceServer).ListTargets(ctx, req.(*ListTargetsRequest))
}
return interceptor(ctx, in, info, handler)
}
// LogtailService_ServiceDesc is the grpc.ServiceDesc for LogtailService service. // LogtailService_ServiceDesc is the grpc.ServiceDesc for LogtailService service.
// It's only intended for direct use with grpc.RegisterService, // It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy) // and not to be introspected or modified (even as a copy)
@@ -189,6 +223,10 @@ var LogtailService_ServiceDesc = grpc.ServiceDesc{
MethodName: "Trend", MethodName: "Trend",
Handler: _LogtailService_Trend_Handler, Handler: _LogtailService_Trend_Handler,
}, },
{
MethodName: "ListTargets",
Handler: _LogtailService_ListTargets_Handler,
},
}, },
Streams: []grpc.StreamDesc{ Streams: []grpc.StreamDesc{
{ {