From 4d29b3ccea6f06dd954ccc35e11d2bf2b946fc00 Mon Sep 17 00:00:00 2001 From: Charlie Le Date: Sun, 19 Jan 2025 13:36:31 -0800 Subject: [PATCH 1/3] feat: add HTML response handling for job and target endpoints Signed-off-by: Charlie Le --- .chloggen/otel-targetallocator-html.yaml | 16 + .../internal/allocation/allocator.go | 1 + cmd/otel-allocator/internal/server/server.go | 454 ++++++++++++- .../internal/server/server_test.go | 631 +++++++++++++++++- cmd/otel-allocator/internal/target/target.go | 8 + 5 files changed, 1104 insertions(+), 6 deletions(-) create mode 100644 .chloggen/otel-targetallocator-html.yaml diff --git a/.chloggen/otel-targetallocator-html.yaml b/.chloggen/otel-targetallocator-html.yaml new file mode 100644 index 0000000000..caffeb8b09 --- /dev/null +++ b/.chloggen/otel-targetallocator-html.yaml @@ -0,0 +1,16 @@ +# One of 'breaking', 'deprecation', 'new_component', 'enhancement', 'bug_fix' +change_type: enhancement + +# The name of the component, or a single word describing the area of concern, (e.g. collector, target allocator, auto-instrumentation, opamp, github action) +component: target allocator + +# A brief description of the change. Surround your text with quotes ("") if it needs to start with a backtick (`). +note: "Adds support for HTML output in the target allocator." + +# One or more tracking issues related to the change +issues: [3622] + +# (Optional) One or more lines of additional information to render under the primary note. +# These lines will be padded with 2 spaces and then inserted directly into the document. +# Use pipe (|) for multiline entries. +subtext: diff --git a/cmd/otel-allocator/internal/allocation/allocator.go b/cmd/otel-allocator/internal/allocation/allocator.go index ac630d04b4..2c1b23a3f0 100644 --- a/cmd/otel-allocator/internal/allocation/allocator.go +++ b/cmd/otel-allocator/internal/allocation/allocator.go @@ -133,6 +133,7 @@ func (a *allocator) GetTargetsForCollectorAndJob(collector string, job string) [ } // TargetItems returns a shallow copy of the targetItems map. +// The key is the target item's hash, and the value is the target item. func (a *allocator) TargetItems() map[string]*target.Item { a.m.RLock() defer a.m.RUnlock() diff --git a/cmd/otel-allocator/internal/server/server.go b/cmd/otel-allocator/internal/server/server.go index 77b43e37da..0de9feae2b 100644 --- a/cmd/otel-allocator/internal/server/server.go +++ b/cmd/otel-allocator/internal/server/server.go @@ -4,12 +4,16 @@ package server import ( + "bytes" "context" "crypto/tls" "fmt" + "html/template" "net/http" "net/http/pprof" "net/url" + "sort" + "strconv" "strings" "sync" "time" @@ -84,8 +88,13 @@ func (s *Server) setRouter(router *gin.Engine) { router.UnescapePathValues = false router.Use(s.PrometheusMiddleware) + router.GET("/", s.IndexHandler) + router.GET("/collector", s.CollectorHTMLHandler) + router.GET("/job", s.JobHTMLHandler) + router.GET("/target", s.TargetHTMLHandler) + router.GET("/targets", s.TargetsHTMLHandler) router.GET("/scrape_configs", s.ScrapeConfigsHandler) - router.GET("/jobs", s.JobHandler) + router.GET("/jobs", s.JobsHandler) router.GET("/jobs/:job_id/targets", s.TargetsHandler) router.GET("/metrics", gin.WrapH(promhttp.Handler())) router.GET("/livez", s.LivenessProbeHandler) @@ -250,11 +259,15 @@ func (s *Server) ReadinessProbeHandler(c *gin.Context) { } } -func (s *Server) JobHandler(c *gin.Context) { +func (s *Server) JobsHandler(c *gin.Context) { displayData := make(map[string]linkJSON) for _, v := range s.allocator.TargetItems() { displayData[v.JobName] = linkJSON{Link: fmt.Sprintf("/jobs/%s/targets", url.QueryEscape(v.JobName))} } + if strings.Contains(c.Request.Header.Get("Accept"), "text/html") { + s.JobsHTMLHandler(c) + return + } s.jsonHandler(c.Writer, displayData) } @@ -269,6 +282,402 @@ func (s *Server) PrometheusMiddleware(c *gin.Context) { timer.ObserveDuration() } +func header(data ...string) string { + return "" + strings.Join(data, "") + "\n" +} + +func row(data ...string) string { + return "" + strings.Join(data, "") + "\n" +} + +// IndexHandler displays the main page of the allocator. It shows the number of jobs and targets. +// It also displays a table with the collectors and the number of jobs and targets for each collector. +// The collector names are links to the respective pages. The table is sorted by collector name. +func (s *Server) IndexHandler(c *gin.Context) { + c.Writer.Header().Set("Content-Type", "text/html") + var b bytes.Buffer + b.WriteString(` + +

OpenTelemetry Target Allocator

+`) + + fmt.Fprint(&b, "\n") + fmt.Fprint(&b, header("Category", "Count")) + fmt.Fprint(&b, row(jobsAnchorLink(), strconv.Itoa(s.getJobCount()))) + fmt.Fprint(&b, row(targetsAnchorLink(), strconv.Itoa(len(s.allocator.TargetItems())))) + fmt.Fprint(&b, "
\n") + + fmt.Fprint(&b, "\n") + fmt.Fprint(&b, header("Collector", "Job Count", "Target Count")) + + // Sort the collectors by name to ensure consistent order + collectorNames := []string{} + for k := range s.allocator.Collectors() { + collectorNames = append(collectorNames, k) + } + sort.Strings(collectorNames) + + for _, colName := range collectorNames { + jobCount := strconv.Itoa(s.getJobCountForCollector(colName)) + targetCount := strconv.Itoa(s.getTargetCountForCollector(colName)) + fmt.Fprint(&b, row(collectorAnchorLink(colName), jobCount, targetCount)) + } + b.WriteString(`
+ +`) + + _, err := c.Writer.Write(b.Bytes()) + if err != nil { + s.logger.Error(err, "failed to write response") + c.Status(http.StatusInternalServerError) + } + + c.Status(http.StatusOK) +} + +func targetsAnchorLink() string { + return `Targets` +} + +// TargetsHTMLHandler displays the targets in a table format. Each target is a row in the table. +// The table has four columns: Job, Target, Collector, and Endpoint Slice. +// The Job, Target, and Collector columns are links to the respective pages. +func (s *Server) TargetsHTMLHandler(c *gin.Context) { + c.Writer.Header().Set("X-Content-Type-Options", "nosniff") + c.Writer.Header().Set("Content-Type", "text/html; charset=utf-8") + + var b bytes.Buffer + b.WriteString(` + +

Targets

+ +`) + fmt.Fprint(&b, header("Job", "Target", "Collector", "Endpoint Slice")) + for _, v := range s.sortedTargetItems() { + fmt.Fprint(&b, row(jobAnchorLink(v.JobName), targetAnchorLink(v), collectorAnchorLink(v.CollectorName), v.GetEndpointSliceName())) + } + + b.WriteString(`
+ +`) + + _, err := c.Writer.Write(b.Bytes()) + if err != nil { + s.logger.Error(err, "failed to write response") + c.Status(http.StatusInternalServerError) + } + + c.Status(http.StatusOK) +} + +func targetAnchorLink(t *target.Item) string { + return fmt.Sprintf("%s", t.Hash(), t.TargetURL) +} + +// TargetHTMLHandler displays information about a target in a table format. +// There are two tables: one for high-level target information and another for the target's labels. +func (s *Server) TargetHTMLHandler(c *gin.Context) { + c.Writer.Header().Set("X-Content-Type-Options", "nosniff") + c.Writer.Header().Set("Content-Type", "text/html; charset=utf-8") + + targetHash := c.Request.URL.Query().Get("target_hash") + if targetHash == "" { + c.Status(http.StatusBadRequest) + _, err := c.Writer.WriteString(` + +

Bad Request

+

Expected target_hash in the query string

+

Example: /target?target_hash=my-target-42

+ +`) + if err != nil { + s.logger.Error(err, "failed to write response") + } + return + } + + target, found := s.allocator.TargetItems()[targetHash] + if !found { + c.Status(http.StatusNotFound) + t, err := template.New("unknown_target").Parse(` + +

Unknown Target: {{.}}

+ +`) + if err != nil { + s.logger.Error(err, "failed to parse template") + } + err = t.Execute(c.Writer, targetHash) + if err != nil { + s.logger.Error(err, "failed to write response") + } + return + } + + var b bytes.Buffer + b.WriteString(` + +

Target: ` + target.TargetURL + `

+ +`) + + fmt.Fprint(&b, row("Collector", target.CollectorName)) + fmt.Fprint(&b, row("Job", target.JobName)) + if namespace := target.Labels.Get("__meta_kubernetes_namespace"); namespace != "" { + fmt.Fprint(&b, row("Namespace", namespace)) + } + if service := target.Labels.Get("__meta_kubernetes_service_name"); service != "" { + fmt.Fprint(&b, row("Service Name", service)) + } + if port := target.Labels.Get("__meta_kubernetes_service_port"); port != "" { + fmt.Fprint(&b, row("Service Port", port)) + } + if podName := target.Labels.Get("__meta_kubernetes_pod_name"); podName != "" { + fmt.Fprint(&b, row("Pod Name", podName)) + } + if container := target.Labels.Get("__meta_kubernetes_pod_container_name"); container != "" { + fmt.Fprint(&b, row("Container Name", container)) + } + if containerPortName := target.Labels.Get("__meta_kubernetes_pod_container_port_name"); containerPortName != "" { + fmt.Fprint(&b, row("Container Port Name", containerPortName)) + } + if node := target.GetNodeName(); node != "" { + fmt.Fprint(&b, row("Node Name", node)) + } + if endpointSliceName := target.GetEndpointSliceName(); endpointSliceName != "" { + fmt.Fprint(&b, row("Endpoint Slice Name", endpointSliceName)) + } + + b.WriteString(`
+

Target Labels

+ +`) + fmt.Fprint(&b, header("Label", "Value")) + for _, l := range target.Labels { + fmt.Fprint(&b, row(l.Name, l.Value)) + } + b.WriteString(`
+ +`) + _, err := c.Writer.Write(b.Bytes()) + if err != nil { + s.logger.Error(err, "failed to write response") + c.Status(http.StatusInternalServerError) + } + + c.Status(http.StatusOK) +} + +func jobsAnchorLink() string { + return `Jobs` +} + +// JobsHTMLHandler displays the jobs in a table format. Each job is a row in the table. +// The table has two columns: Job and Target Count. The Job column is a link to the job's targets. +func (s *Server) JobsHTMLHandler(c *gin.Context) { + c.Writer.Header().Set("X-Content-Type-Options", "nosniff") + c.Writer.Header().Set("Content-Type", "text/html; charset=utf-8") + + var b bytes.Buffer + b.WriteString(` + +

Jobs

+ +`) + fmt.Fprint(&b, header("Job", "Target Count")) + + jobs := make(map[string]int) + for _, v := range s.allocator.TargetItems() { + jobs[v.JobName]++ + } + + // Sort the jobs by name to ensure consistent order + jobNames := make([]string, 0, len(jobs)) + for k := range jobs { + jobNames = append(jobNames, k) + } + sort.Strings(jobNames) + + for _, j := range jobNames { + fmt.Fprint(&b, row(jobAnchorLink(j), strconv.Itoa(jobs[j]))) + } + + b.WriteString(`
+ +`) + + _, err := c.Writer.Write(b.Bytes()) + if err != nil { + s.logger.Error(err, "failed to write response") + c.Status(http.StatusInternalServerError) + } + + c.Status(http.StatusOK) +} + +func jobAnchorLink(jobId string) string { + return fmt.Sprintf("%s", jobId, jobId) +} +func (s *Server) JobHTMLHandler(c *gin.Context) { + c.Writer.Header().Set("X-Content-Type-Options", "nosniff") + c.Writer.Header().Set("Content-Type", "text/html; charset=utf-8") + + jobIdValues := c.Request.URL.Query()["job_id"] + if len(jobIdValues) != 1 { + c.Status(http.StatusBadRequest) + return + } + jobId := jobIdValues[0] + + var b bytes.Buffer + t, err := template.New("job").Parse(` + +

Job: {{.}}

+ +`) + if err != nil { + s.logger.Error(err, "failed to parse template") + return + } + err = t.Execute(&b, jobId) + if err != nil { + s.logger.Error(err, "failed to execute template") + return + } + fmt.Fprint(&b, header("Collector", "Target Count")) + + // Filter targets by job + targets := map[string]*target.Item{} + for k, v := range s.allocator.TargetItems() { + if v.JobName == jobId { + targets[k] = v + } + } + + colNames := []string{} + for _, col := range s.allocator.Collectors() { + colNames = append(colNames, col.Name) + } + sort.Strings(colNames) + + for _, colName := range colNames { + count := 0 + for _, target := range targets { + if target.CollectorName == colName { + count++ + } + } + fmt.Fprint(&b, row(collectorAnchorLink(colName), strconv.Itoa(count))) + } + b.WriteString(`
+ +`) + fmt.Fprint(&b, header("Collector", "Target")) + for _, v := range colNames { + for _, t := range targets { + if t.CollectorName == v { + fmt.Fprint(&b, row(collectorAnchorLink(v), targetAnchorLink(t))) + } + } + } + b.WriteString(`
+ +`) + _, err = c.Writer.Write(b.Bytes()) + if err != nil { + s.logger.Error(err, "failed to write response") + c.Status(http.StatusInternalServerError) + } + + c.Status(http.StatusOK) +} + +func collectorAnchorLink(collectorId string) string { + return fmt.Sprintf("%s", collectorId, collectorId) +} + +func (s *Server) CollectorHTMLHandler(c *gin.Context) { + c.Writer.Header().Set("X-Content-Type-Options", "nosniff") + c.Writer.Header().Set("Content-Type", "text/html; charset=utf-8") + collectorIdValues := c.Request.URL.Query()["collector_id"] + collectorId := "" + if len(collectorIdValues) == 1 { + collectorId = collectorIdValues[0] + } + + if collectorId == "" { + c.Status(http.StatusBadRequest) + _, err := c.Writer.WriteString(` + +

Bad Request

+

Expected collector_id in the query string

+

Example: /collector?collector_id=my-collector-42

+ +`) + if err != nil { + s.logger.Error(err, "failed to write response") + } + return + } + + found := false + for _, v := range s.allocator.Collectors() { + if v.Name == collectorId { + found = true + break + } + } + if !found { + c.Status(http.StatusNotFound) + t, err := template.New("unknown_collector").Parse(` + +

Unknown Collector: {{.}}

+ +`) + if err != nil { + s.logger.Error(err, "failed to parse template") + } + err = t.Execute(c.Writer, collectorId) + if err != nil { + s.logger.Error(err, "failed to write response") + } + return + } + + var b bytes.Buffer + t, err := template.New("collector").Parse(` + +

Collector: {{.}}

+ +`) + if err != nil { + s.logger.Error(err, "failed to parse template") + return + } + err = t.Execute(&b, collectorId) + if err != nil { + s.logger.Error(err, "failed to execute template") + return + } + + fmt.Fprint(&b, header("Job", "Target", "Endpoint Slice")) + for _, v := range s.sortedTargetItems() { + if v.CollectorName == collectorId { + fmt.Fprint(&b, row(jobAnchorLink(v.JobName), targetAnchorLink(v), v.GetEndpointSliceName())) + } + } + b.WriteString(`
+ +`) + _, err = c.Writer.Write(b.Bytes()) + if err != nil { + s.logger.Error(err, "failed to write response") + c.Status(http.StatusInternalServerError) + } + + c.Status(http.StatusOK) +} + func (s *Server) TargetsHandler(c *gin.Context) { q := c.Request.URL.Query()["collector_id"] @@ -291,7 +700,6 @@ func (s *Server) TargetsHandler(c *gin.Context) { } s.jsonHandler(c.Writer, targets) } - } func (s *Server) errorHandler(w http.ResponseWriter, err error) { @@ -307,6 +715,46 @@ func (s *Server) jsonHandler(w http.ResponseWriter, data interface{}) { } } +// sortedTargetItems returns a sorted list of target items by its hash. +func (s *Server) sortedTargetItems() []*target.Item { + targetItems := make([]*target.Item, 0, len(s.allocator.TargetItems())) + for _, v := range s.allocator.TargetItems() { + targetItems = append(targetItems, v) + } + sort.Slice(targetItems, func(i, j int) bool { + return targetItems[i].Hash() < targetItems[j].Hash() + }) + return targetItems +} + +func (s *Server) getJobCount() int { + jobs := make(map[string]struct{}) + for _, v := range s.allocator.TargetItems() { + jobs[v.JobName] = struct{}{} + } + return len(jobs) +} + +func (s *Server) getJobCountForCollector(collector string) int { + jobs := make(map[string]struct{}) + for _, v := range s.allocator.TargetItems() { + if v.CollectorName == collector { + jobs[v.JobName] = struct{}{} + } + } + return len(jobs) +} + +func (s *Server) getTargetCountForCollector(collector string) int { + count := 0 + for _, v := range s.allocator.TargetItems() { + if v.CollectorName == collector { + count++ + } + } + return count +} + // GetAllTargetsByJob is a relatively expensive call that is usually only used for debugging purposes. func GetAllTargetsByJob(allocator allocation.Allocator, job string) map[string]collectorJSON { displayData := make(map[string]collectorJSON) diff --git a/cmd/otel-allocator/internal/server/server_test.go b/cmd/otel-allocator/internal/server/server_test.go index fce2ef0c01..ef0280d160 100644 --- a/cmd/otel-allocator/internal/server/server_test.go +++ b/cmd/otel-allocator/internal/server/server_test.go @@ -11,6 +11,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "strings" "testing" "time" @@ -37,9 +38,10 @@ var ( testJobLabelSetTwo = labels.Labels{ {Name: "test_label", Value: "test-value2"}, } - baseTargetItem = target.NewItem("test-job", "test-url", baseLabelSet, "test-collector") - secondTargetItem = target.NewItem("test-job", "test-url", baseLabelSet, "test-collector") - testJobTargetItemTwo = target.NewItem("test-job", "test-url2", testJobLabelSetTwo, "test-collector2") + baseTargetItem = target.NewItem("test-job", "test-url", baseLabelSet, "test-collector") + secondTargetItem = target.NewItem("test-job", "test-url", baseLabelSet, "test-collector") + testJobTargetItemTwo = target.NewItem("test-job", "test-url2", testJobLabelSetTwo, "test-collector2") + testJobTwoTargetItemTwo = target.NewItem("test-job2", "test-url3", testJobLabelSetTwo, "test-collector2") ) func TestServer_LivenessProbeHandler(t *testing.T) { @@ -606,6 +608,629 @@ func TestServer_JobHandler(t *testing.T) { }) } } +func TestServer_JobsHandler_HTML(t *testing.T) { + tests := []struct { + description string + targetItems map[string]*target.Item + expectedCode int + expectedJobs string + }{ + { + description: "nil jobs", + targetItems: nil, + expectedCode: http.StatusOK, + expectedJobs: ` + +

Jobs

+ + +
JobTarget Count
+ +`, + }, + { + description: "empty jobs", + targetItems: map[string]*target.Item{}, + expectedCode: http.StatusOK, + expectedJobs: ` + +

Jobs

+ + +
JobTarget Count
+ +`, + }, + { + description: "one job", + targetItems: map[string]*target.Item{ + "targetitem": target.NewItem("job1", "", labels.Labels{}, ""), + }, + expectedCode: http.StatusOK, + expectedJobs: ` + +

Jobs

+ + + +
JobTarget Count
job11
+ +`, + }, + { + description: "multiple jobs", + targetItems: map[string]*target.Item{ + "a": target.NewItem("job1", "1.1.1.1:8080", labels.Labels{}, ""), + "b": target.NewItem("job2", "1.1.1.2:8080", labels.Labels{}, ""), + "c": target.NewItem("job3", "1.1.1.3:8080", labels.Labels{}, ""), + "d": target.NewItem("job3", "1.1.1.4:8080", labels.Labels{}, ""), + "e": target.NewItem("job3", "1.1.1.5:8080", labels.Labels{}, "")}, + expectedCode: http.StatusOK, + expectedJobs: ` + +

Jobs

+ + + + + +
JobTarget Count
job11
job21
job33
+ +`, + }, + } + for _, tc := range tests { + t.Run(tc.description, func(t *testing.T) { + listenAddr := ":8080" + a := &mockAllocator{targetItems: tc.targetItems} + s := NewServer(logger, a, listenAddr) + a.SetCollectors(map[string]*allocation.Collector{ + "test-collector": {Name: "test-collector"}, + "test-collector2": {Name: "test-collector2"}, + }) + request := httptest.NewRequest("GET", "/jobs", nil) + request.Header.Set("Accept", "text/html") + w := httptest.NewRecorder() + + s.server.Handler.ServeHTTP(w, request) + result := w.Result() + + assert.Equal(t, tc.expectedCode, result.StatusCode) + bodyBytes, err := io.ReadAll(result.Body) + require.NoError(t, err) + assert.Equal(t, tc.expectedJobs, string(bodyBytes)) + }) + } +} + +func TestServer_JobHandler_HTML(t *testing.T) { + consistentHashing, _ := allocation.New("consistent-hashing", logger) + type args struct { + job string + cMap map[string]*target.Item + allocator allocation.Allocator + } + type want struct { + items string + errString string + } + tests := []struct { + name string + args args + want want + }{ + { + name: "Empty target map", + args: args{ + job: "test-job", + cMap: map[string]*target.Item{}, + allocator: consistentHashing, + }, + want: want{ + items: ` + +

Job: test-job

+ + + + +
CollectorTarget Count
test-collector0
test-collector20
+ + +
CollectorTarget
+ +`}, + }, + { + name: "Single entry target map", + args: args{ + job: "test-job", + cMap: map[string]*target.Item{ + baseTargetItem.Hash(): baseTargetItem, + }, + allocator: consistentHashing, + }, + want: want{ + items: ` + +

Job: test-job

+ + + + +
CollectorTarget Count
test-collector0
test-collector21
+ + + +
CollectorTarget
test-collector2test-url
+ +`, + }, + }, + { + name: "Multiple entry target map", + args: args{ + job: "test-job", + cMap: map[string]*target.Item{ + baseTargetItem.Hash(): baseTargetItem, + testJobTwoTargetItemTwo.Hash(): testJobTwoTargetItemTwo, + }, + allocator: consistentHashing, + }, + want: want{ + items: ` + +

Job: test-job

+ + + + +
CollectorTarget Count
test-collector0
test-collector21
+ + + +
CollectorTarget
test-collector2test-url
+ +`, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + listenAddr := ":8080" + s := NewServer(logger, tt.args.allocator, listenAddr) + tt.args.allocator.SetCollectors(map[string]*allocation.Collector{ + "test-collector": {Name: "test-collector"}, + "test-collector2": {Name: "test-collector2"}, + }) + tt.args.allocator.SetTargets(tt.args.cMap) + request := httptest.NewRequest("GET", fmt.Sprintf("/job?job_id=%s", tt.args.job), nil) + request.Header.Set("Accept", "text/html") + w := httptest.NewRecorder() + + s.server.Handler.ServeHTTP(w, request) + result := w.Result() + + assert.Equal(t, http.StatusOK, result.StatusCode) + body := result.Body + bodyBytes, err := io.ReadAll(body) + assert.NoError(t, err) + if len(tt.want.errString) != 0 { + assert.EqualError(t, err, tt.want.errString) + return + } + assert.Equal(t, tt.want.items, string(bodyBytes)) + }) + } +} + +func TestServer_IndexHandler(t *testing.T) { + allocator, _ := allocation.New("consistent-hashing", logger) + tests := []struct { + description string + allocator allocation.Allocator + targetItems map[string]*target.Item + expectedHTML string + }{ + { + description: "Empty target map", + targetItems: map[string]*target.Item{}, + allocator: allocator, + expectedHTML: strings.Trim(` + + +

OpenTelemetry Target Allocator

+ + + + +
CategoryCount
Jobs0
Targets0
+ + + + +
CollectorJob CountTarget Count
test-collector100
test-collector200
+ + +`, "\n"), + }, + { + description: "Single entry target map", + targetItems: map[string]*target.Item{ + baseTargetItem.Hash(): baseTargetItem, + }, + allocator: allocator, + expectedHTML: strings.Trim(` + + +

OpenTelemetry Target Allocator

+ + + + +
CategoryCount
Jobs1
Targets1
+ + + + +
CollectorJob CountTarget Count
test-collector111
test-collector200
+ + +`, "\n"), + }, + { + description: "Multiple entry target map", + targetItems: map[string]*target.Item{ + baseTargetItem.Hash(): baseTargetItem, + testJobTargetItemTwo.Hash(): testJobTargetItemTwo, + testJobTwoTargetItemTwo.Hash(): testJobTwoTargetItemTwo, + }, + allocator: allocator, + expectedHTML: strings.Trim(` + + +

OpenTelemetry Target Allocator

+ + + + +
CategoryCount
Jobs2
Targets3
+ + + + +
CollectorJob CountTarget Count
test-collector122
test-collector211
+ + +`, "\n"), + }, + } + for _, tc := range tests { + t.Run(tc.description, func(t *testing.T) { + listenAddr := ":8080" + s := NewServer(logger, tc.allocator, listenAddr) + tc.allocator.SetCollectors(map[string]*allocation.Collector{ + "test-collector1": {Name: "test-collector1"}, + "test-collector2": {Name: "test-collector2"}, + }) + tc.allocator.SetTargets(tc.targetItems) + request := httptest.NewRequest("GET", "/", nil) + request.Header.Set("Accept", "text/html") + w := httptest.NewRecorder() + + s.server.Handler.ServeHTTP(w, request) + result := w.Result() + + assert.Equal(t, http.StatusOK, result.StatusCode) + body := result.Body + bodyBytes, err := io.ReadAll(body) + assert.NoError(t, err) + assert.Equal(t, tc.expectedHTML, string(bodyBytes)) + }) + } +} +func TestServer_TargetsHTMLHandler(t *testing.T) { + allocator, _ := allocation.New("consistent-hashing", logger) + tests := []struct { + description string + allocator allocation.Allocator + targetItems map[string]*target.Item + expectedHTML string + }{ + { + description: "Empty target map", + targetItems: map[string]*target.Item{}, + allocator: allocator, + expectedHTML: ` + +

Targets

+ + +
JobTargetCollectorEndpoint Slice
+ +`, + }, + { + description: "Single entry target map", + targetItems: map[string]*target.Item{ + baseTargetItem.Hash(): baseTargetItem, + }, + allocator: allocator, + expectedHTML: ` + +

Targets

+ + + +
JobTargetCollectorEndpoint Slice
test-jobtest-urltest-collector1
+ +`, + }, + { + description: "Multiple entry target map", + targetItems: map[string]*target.Item{ + baseTargetItem.Hash(): baseTargetItem, + testJobTargetItemTwo.Hash(): testJobTargetItemTwo, + testJobTwoTargetItemTwo.Hash(): testJobTwoTargetItemTwo, + }, + allocator: allocator, + expectedHTML: ` + +

Targets

+ + + + + +
JobTargetCollectorEndpoint Slice
test-job2test-url3test-collector1
test-jobtest-url2test-collector2
test-jobtest-urltest-collector1
+ +`, + }, + } + for _, tc := range tests { + t.Run(tc.description, func(t *testing.T) { + listenAddr := ":8080" + s := NewServer(logger, tc.allocator, listenAddr) + tc.allocator.SetCollectors(map[string]*allocation.Collector{ + "test-collector1": {Name: "test-collector1"}, + "test-collector2": {Name: "test-collector2"}, + }) + tc.allocator.SetTargets(tc.targetItems) + request := httptest.NewRequest("GET", "/targets", nil) + request.Header.Set("Accept", "text/html") + w := httptest.NewRecorder() + + s.server.Handler.ServeHTTP(w, request) + result := w.Result() + + assert.Equal(t, http.StatusOK, result.StatusCode) + body := result.Body + bodyBytes, err := io.ReadAll(body) + assert.NoError(t, err) + assert.Equal(t, tc.expectedHTML, string(bodyBytes)) + }) + } +} + +func TestServer_CollectorHandler(t *testing.T) { + allocator, _ := allocation.New("consistent-hashing", logger) + tests := []struct { + description string + collectorId string + allocator allocation.Allocator + targetItems map[string]*target.Item + expectedCode int + expectedHTML string + }{ + { + description: "Empty target map", + collectorId: "test-collector", + targetItems: map[string]*target.Item{}, + allocator: allocator, + expectedCode: http.StatusOK, + expectedHTML: ` + +

Collector: test-collector

+ + +
JobTargetEndpoint Slice
+ +`, + }, + { + description: "Single entry target map", + collectorId: "test-collector2", + targetItems: map[string]*target.Item{ + baseTargetItem.Hash(): baseTargetItem, + }, + allocator: allocator, + expectedCode: http.StatusOK, + expectedHTML: ` + +

Collector: test-collector2

+ + + +
JobTargetEndpoint Slice
test-jobtest-url
+ +`, + }, + { + description: "Multiple entry target map", + collectorId: "test-collector2", + targetItems: map[string]*target.Item{ + baseTargetItem.Hash(): baseTargetItem, + testJobTwoTargetItemTwo.Hash(): testJobTwoTargetItemTwo, + }, + allocator: allocator, + expectedCode: http.StatusOK, + expectedHTML: ` + +

Collector: test-collector2

+ + + +
JobTargetEndpoint Slice
test-jobtest-url
+ +`, + }, + { + description: "Multiple entry target map, collector id is empty", + collectorId: "", + targetItems: map[string]*target.Item{ + baseTargetItem.Hash(): baseTargetItem, + testJobTwoTargetItemTwo.Hash(): testJobTwoTargetItemTwo, + }, + allocator: allocator, + expectedCode: http.StatusBadRequest, + expectedHTML: ` + +

Bad Request

+

Expected collector_id in the query string

+

Example: /collector?collector_id=my-collector-42

+ +`, + }, + { + description: "Multiple entry target map, unknown collector id", + collectorId: "unknown-collector-1", + targetItems: map[string]*target.Item{ + baseTargetItem.Hash(): baseTargetItem, + testJobTwoTargetItemTwo.Hash(): testJobTwoTargetItemTwo, + }, + allocator: allocator, + expectedCode: http.StatusNotFound, + expectedHTML: ` + +

Unknown Collector: unknown-collector-1

+ +`, + }, + } + for _, tc := range tests { + t.Run(tc.description, func(t *testing.T) { + listenAddr := ":8080" + s := NewServer(logger, tc.allocator, listenAddr) + tc.allocator.SetCollectors(map[string]*allocation.Collector{ + "test-collector": {Name: "test-collector"}, + "test-collector2": {Name: "test-collector2"}, + }) + tc.allocator.SetTargets(tc.targetItems) + request := httptest.NewRequest("GET", "/collector", nil) + request.Header.Set("Accept", "text/html") + request.URL.RawQuery = "collector_id=" + tc.collectorId + w := httptest.NewRecorder() + + s.server.Handler.ServeHTTP(w, request) + result := w.Result() + + assert.Equal(t, tc.expectedCode, result.StatusCode) + body := result.Body + bodyBytes, err := io.ReadAll(body) + assert.NoError(t, err) + assert.Equal(t, tc.expectedHTML, string(bodyBytes)) + }) + } +} + +func TestServer_TargetHTMLHandler(t *testing.T) { + allocator, _ := allocation.New("consistent-hashing", logger) + tests := []struct { + description string + targetHash string + allocator allocation.Allocator + targetItems map[string]*target.Item + expectedCode int + expectedHTML string + }{ + { + description: "Missing target hash", + targetHash: "", + targetItems: map[string]*target.Item{}, + allocator: allocator, + expectedCode: http.StatusBadRequest, + expectedHTML: ` + +

Bad Request

+

Expected target_hash in the query string

+

Example: /target?target_hash=my-target-42

+ +`, + }, + { + description: "Single entry target map", + targetHash: baseTargetItem.Hash(), + targetItems: map[string]*target.Item{ + baseTargetItem.Hash(): baseTargetItem, + }, + allocator: allocator, + expectedCode: http.StatusOK, + expectedHTML: ` + +

Target: test-url

+ + + +
Collectortest-collector2
Jobtest-job
+

Target Labels

+ + + +
LabelValue
test_labeltest-value
+ +`, + }, + { + description: "Multiple entry target map", + targetHash: testJobTwoTargetItemTwo.Hash(), + targetItems: map[string]*target.Item{ + baseTargetItem.Hash(): baseTargetItem, + testJobTwoTargetItemTwo.Hash(): testJobTwoTargetItemTwo, + }, + allocator: allocator, + expectedCode: http.StatusOK, + expectedHTML: ` + +

Target: test-url3

+ + + +
Collectortest-collector
Jobtest-job2
+

Target Labels

+ + + +
LabelValue
test_labeltest-value2
+ +`, + }, + } + for _, tc := range tests { + t.Run(tc.description, func(t *testing.T) { + listenAddr := ":8080" + s := NewServer(logger, tc.allocator, listenAddr) + tc.allocator.SetCollectors(map[string]*allocation.Collector{ + "test-collector": {Name: "test-collector"}, + "test-collector2": {Name: "test-collector2"}, + }) + tc.allocator.SetTargets(tc.targetItems) + request := httptest.NewRequest("GET", "/target", nil) + request.Header.Set("Accept", "text/html") + request.URL.RawQuery = "target_hash=" + tc.targetHash + w := httptest.NewRecorder() + + s.server.Handler.ServeHTTP(w, request) + result := w.Result() + + assert.Equal(t, tc.expectedCode, result.StatusCode) + body := result.Body + bodyBytes, err := io.ReadAll(body) + assert.NoError(t, err) + assert.Equal(t, tc.expectedHTML, string(bodyBytes)) + }) + } +} + func TestServer_Readiness(t *testing.T) { tests := []struct { description string diff --git a/cmd/otel-allocator/internal/target/target.go b/cmd/otel-allocator/internal/target/target.go index 2a1777273d..51b7bd2159 100644 --- a/cmd/otel-allocator/internal/target/target.go +++ b/cmd/otel-allocator/internal/target/target.go @@ -20,9 +20,11 @@ var ( } endpointSliceTargetKindLabel = "__meta_kubernetes_endpointslice_address_target_kind" endpointSliceTargetNameLabel = "__meta_kubernetes_endpointslice_address_target_name" + endpointSliceName = "__meta_kubernetes_endpointslice_name" relevantLabelNames = append(nodeLabels, endpointSliceTargetKindLabel, endpointSliceTargetNameLabel) ) +// Item represents a target to be scraped. type Item struct { JobName string TargetURL string @@ -50,6 +52,12 @@ func (t *Item) GetNodeName() string { return relevantLabels.Get(endpointSliceTargetNameLabel) } +// GetEndpointSliceName returns the name of the EndpointSlice that the target is part of. +// If the target is not part of an EndpointSlice, it returns an empty string. +func (t *Item) GetEndpointSliceName() string { + return t.Labels.Get(endpointSliceName) +} + // NewItem Creates a new target item. // INVARIANTS: // * Item fields must not be modified after creation. From b4f43e2cbe560706164eb32fd2b91767858b4642 Mon Sep 17 00:00:00 2001 From: Charlie Le Date: Sun, 16 Mar 2025 13:32:33 -0700 Subject: [PATCH 2/3] Add UI for scrape_configs, embed html templates, and setup golden files Signed-off-by: Charlie Le --- cmd/otel-allocator/README.md | 1 - cmd/otel-allocator/internal/server/server.go | 455 +++++++++--------- .../internal/server/server_test.go | 307 ++---------- .../internal/server/templates.go | 69 +++ .../server/templates/page_footer.html | 2 + .../server/templates/page_header.html | 16 + .../server/templates/properties_table.html | 19 + .../internal/server/templates_test.go | 45 ++ .../server/testdata/collector_empty.html | 31 ++ .../server/testdata/collector_empty_id.html | 7 + .../server/testdata/collector_multiple.html | 42 ++ .../server/testdata/collector_single.html | 42 ++ .../server/testdata/collector_unknown_id.html | 5 + .../internal/server/testdata/index_empty.html | 87 ++++ .../server/testdata/index_multiple.html | 87 ++++ .../server/testdata/index_single.html | 87 ++++ .../internal/server/testdata/job_empty.html | 44 ++ .../server/testdata/job_multiple.html | 44 ++ .../internal/server/testdata/job_single.html | 44 ++ .../internal/server/testdata/jobs_empty.html | 28 ++ .../server/testdata/jobs_multiple.html | 52 ++ .../internal/server/testdata/jobs_one.html | 36 ++ .../server/testdata/target_empty_hash.html | 7 + .../server/testdata/target_multiple.html | 126 +++++ .../server/testdata/target_single.html | 126 +++++ .../server/testdata/targets_empty.html | 34 ++ .../server/testdata/targets_multiple.html | 76 +++ .../server/testdata/targets_single.html | 48 ++ go.mod | 2 + scripts/update-golden-files.sh | 20 + 30 files changed, 1484 insertions(+), 505 deletions(-) create mode 100644 cmd/otel-allocator/internal/server/templates.go create mode 100644 cmd/otel-allocator/internal/server/templates/page_footer.html create mode 100644 cmd/otel-allocator/internal/server/templates/page_header.html create mode 100644 cmd/otel-allocator/internal/server/templates/properties_table.html create mode 100644 cmd/otel-allocator/internal/server/templates_test.go create mode 100644 cmd/otel-allocator/internal/server/testdata/collector_empty.html create mode 100644 cmd/otel-allocator/internal/server/testdata/collector_empty_id.html create mode 100644 cmd/otel-allocator/internal/server/testdata/collector_multiple.html create mode 100644 cmd/otel-allocator/internal/server/testdata/collector_single.html create mode 100644 cmd/otel-allocator/internal/server/testdata/collector_unknown_id.html create mode 100644 cmd/otel-allocator/internal/server/testdata/index_empty.html create mode 100644 cmd/otel-allocator/internal/server/testdata/index_multiple.html create mode 100644 cmd/otel-allocator/internal/server/testdata/index_single.html create mode 100644 cmd/otel-allocator/internal/server/testdata/job_empty.html create mode 100644 cmd/otel-allocator/internal/server/testdata/job_multiple.html create mode 100644 cmd/otel-allocator/internal/server/testdata/job_single.html create mode 100644 cmd/otel-allocator/internal/server/testdata/jobs_empty.html create mode 100644 cmd/otel-allocator/internal/server/testdata/jobs_multiple.html create mode 100644 cmd/otel-allocator/internal/server/testdata/jobs_one.html create mode 100644 cmd/otel-allocator/internal/server/testdata/target_empty_hash.html create mode 100644 cmd/otel-allocator/internal/server/testdata/target_multiple.html create mode 100644 cmd/otel-allocator/internal/server/testdata/target_single.html create mode 100644 cmd/otel-allocator/internal/server/testdata/targets_empty.html create mode 100644 cmd/otel-allocator/internal/server/testdata/targets_multiple.html create mode 100644 cmd/otel-allocator/internal/server/testdata/targets_single.html create mode 100755 scripts/update-golden-files.sh diff --git a/cmd/otel-allocator/README.md b/cmd/otel-allocator/README.md index 6db1058108..049e2cca68 100644 --- a/cmd/otel-allocator/README.md +++ b/cmd/otel-allocator/README.md @@ -420,4 +420,3 @@ Shards the received targets based on the discovered Collector instances ### Collector Client to watch for deployed Collector instances which will then provided to the Allocator. - diff --git a/cmd/otel-allocator/internal/server/server.go b/cmd/otel-allocator/internal/server/server.go index 0de9feae2b..688d215d77 100644 --- a/cmd/otel-allocator/internal/server/server.go +++ b/cmd/otel-allocator/internal/server/server.go @@ -4,7 +4,6 @@ package server import ( - "bytes" "context" "crypto/tls" "fmt" @@ -232,6 +231,10 @@ func (s *Server) UpdateScrapeConfigResponse(configs map[string]*promconfig.Scrap // ScrapeConfigsHandler returns the available scrape configuration discovered by the target allocator. func (s *Server) ScrapeConfigsHandler(c *gin.Context) { + if strings.Contains(c.Request.Header.Get("Accept"), "text/html") { + s.ScrapeConfigsHTMLHandler(c) + return + } s.mtx.RLock() result := s.scrapeConfigResponse if c.Request.TLS != nil { @@ -295,47 +298,40 @@ func row(data ...string) string { // The collector names are links to the respective pages. The table is sorted by collector name. func (s *Server) IndexHandler(c *gin.Context) { c.Writer.Header().Set("Content-Type", "text/html") - var b bytes.Buffer - b.WriteString(` - -

OpenTelemetry Target Allocator

-`) - - fmt.Fprint(&b, "\n") - fmt.Fprint(&b, header("Category", "Count")) - fmt.Fprint(&b, row(jobsAnchorLink(), strconv.Itoa(s.getJobCount()))) - fmt.Fprint(&b, row(targetsAnchorLink(), strconv.Itoa(len(s.allocator.TargetItems())))) - fmt.Fprint(&b, "
\n") - - fmt.Fprint(&b, "\n") - fmt.Fprint(&b, header("Collector", "Job Count", "Target Count")) - - // Sort the collectors by name to ensure consistent order - collectorNames := []string{} - for k := range s.allocator.Collectors() { - collectorNames = append(collectorNames, k) - } - sort.Strings(collectorNames) - - for _, colName := range collectorNames { - jobCount := strconv.Itoa(s.getJobCountForCollector(colName)) - targetCount := strconv.Itoa(s.getTargetCountForCollector(colName)) - fmt.Fprint(&b, row(collectorAnchorLink(colName), jobCount, targetCount)) - } - b.WriteString(`
- -`) + WriteHTMLPageHeader(c.Writer, HeaderData{ + Title: "OpenTelemetry Target Allocator", + }) - _, err := c.Writer.Write(b.Bytes()) - if err != nil { - s.logger.Error(err, "failed to write response") - c.Status(http.StatusInternalServerError) - } + WriteHTMLPropertiesTable(c.Writer, PropertiesTableData{ + Headers: []string{"Category", "Count"}, + Rows: [][]template.HTML{ + {scrapeConfigAnchorLink(), template.HTML(strconv.Itoa(s.getScrapeConfigCount()))}, + {jobsAnchorLink(), template.HTML(strconv.Itoa(s.getJobCount()))}, + {targetsAnchorLink(), template.HTML(strconv.Itoa(len(s.allocator.TargetItems())))}, + }, + }) + WriteHTMLPropertiesTable(c.Writer, PropertiesTableData{ + Headers: []string{"Collector", "Job Count", "Target Count"}, + Rows: func() [][]template.HTML { + var rows [][]template.HTML + collectorNames := []string{} + for k := range s.allocator.Collectors() { + collectorNames = append(collectorNames, k) + } + sort.Strings(collectorNames) - c.Status(http.StatusOK) + for _, colName := range collectorNames { + jobCount := strconv.Itoa(s.getJobCountForCollector(colName)) + targetCount := strconv.Itoa(s.getTargetCountForCollector(colName)) + rows = append(rows, []template.HTML{collectorAnchorLink(colName), template.HTML(jobCount), template.HTML(targetCount)}) + } + return rows + }(), + }) + WriteHTMLPageFooter(c.Writer) } -func targetsAnchorLink() string { +func targetsAnchorLink() template.HTML { return `Targets` } @@ -346,32 +342,30 @@ func (s *Server) TargetsHTMLHandler(c *gin.Context) { c.Writer.Header().Set("X-Content-Type-Options", "nosniff") c.Writer.Header().Set("Content-Type", "text/html; charset=utf-8") - var b bytes.Buffer - b.WriteString(` - -

Targets

- -`) - fmt.Fprint(&b, header("Job", "Target", "Collector", "Endpoint Slice")) - for _, v := range s.sortedTargetItems() { - fmt.Fprint(&b, row(jobAnchorLink(v.JobName), targetAnchorLink(v), collectorAnchorLink(v.CollectorName), v.GetEndpointSliceName())) - } - - b.WriteString(`
- -`) - - _, err := c.Writer.Write(b.Bytes()) - if err != nil { - s.logger.Error(err, "failed to write response") - c.Status(http.StatusInternalServerError) - } + WriteHTMLPageHeader(c.Writer, HeaderData{ + Title: "OpenTelemetry Target Allocator - Targets", + }) - c.Status(http.StatusOK) + WriteHTMLPropertiesTable(c.Writer, PropertiesTableData{ + Headers: []string{"Job", "Target", "Collector", "Endpoint Slice"}, + Rows: func() [][]template.HTML { + var rows [][]template.HTML + for _, v := range s.sortedTargetItems() { + rows = append(rows, []template.HTML{ + jobAnchorLink(v.JobName), + targetAnchorLink(v), + collectorAnchorLink(v.CollectorName), + template.HTML(v.GetEndpointSliceName()), + }) + } + return rows + }(), + }) + WriteHTMLPageFooter(c.Writer) } -func targetAnchorLink(t *target.Item) string { - return fmt.Sprintf("%s", t.Hash(), t.TargetURL) +func targetAnchorLink(t *target.Item) template.HTML { + return template.HTML(fmt.Sprintf("%s", t.Hash(), t.TargetURL)) } // TargetHTMLHandler displays information about a target in a table format. @@ -414,61 +408,38 @@ func (s *Server) TargetHTMLHandler(c *gin.Context) { return } - var b bytes.Buffer - b.WriteString(` - -

Target: ` + target.TargetURL + `

- -`) - - fmt.Fprint(&b, row("Collector", target.CollectorName)) - fmt.Fprint(&b, row("Job", target.JobName)) - if namespace := target.Labels.Get("__meta_kubernetes_namespace"); namespace != "" { - fmt.Fprint(&b, row("Namespace", namespace)) - } - if service := target.Labels.Get("__meta_kubernetes_service_name"); service != "" { - fmt.Fprint(&b, row("Service Name", service)) - } - if port := target.Labels.Get("__meta_kubernetes_service_port"); port != "" { - fmt.Fprint(&b, row("Service Port", port)) - } - if podName := target.Labels.Get("__meta_kubernetes_pod_name"); podName != "" { - fmt.Fprint(&b, row("Pod Name", podName)) - } - if container := target.Labels.Get("__meta_kubernetes_pod_container_name"); container != "" { - fmt.Fprint(&b, row("Container Name", container)) - } - if containerPortName := target.Labels.Get("__meta_kubernetes_pod_container_port_name"); containerPortName != "" { - fmt.Fprint(&b, row("Container Port Name", containerPortName)) - } - if node := target.GetNodeName(); node != "" { - fmt.Fprint(&b, row("Node Name", node)) - } - if endpointSliceName := target.GetEndpointSliceName(); endpointSliceName != "" { - fmt.Fprint(&b, row("Endpoint Slice Name", endpointSliceName)) - } - - b.WriteString(`
-

Target Labels

- -`) - fmt.Fprint(&b, header("Label", "Value")) - for _, l := range target.Labels { - fmt.Fprint(&b, row(l.Name, l.Value)) - } - b.WriteString(`
- -`) - _, err := c.Writer.Write(b.Bytes()) - if err != nil { - s.logger.Error(err, "failed to write response") - c.Status(http.StatusInternalServerError) - } - - c.Status(http.StatusOK) + WriteHTMLPageHeader(c.Writer, HeaderData{ + Title: "Target: " + target.TargetURL, + }) + WriteHTMLPropertiesTable(c.Writer, PropertiesTableData{ + Headers: []string{"", ""}, + Rows: [][]template.HTML{ + {"Collector", collectorAnchorLink(target.CollectorName)}, + {"Job", jobAnchorLink(target.JobName)}, + {"Namespace", template.HTML(target.Labels.Get("__meta_kubernetes_namespace"))}, + {"Service Name", template.HTML(target.Labels.Get("__meta_kubernetes_service_name"))}, + {"Service Port", template.HTML(target.Labels.Get("__meta_kubernetes_service_port"))}, + {"Pod Name", template.HTML(target.Labels.Get("__meta_kubernetes_pod_name"))}, + {"Container Name", template.HTML(target.Labels.Get("__meta_kubernetes_pod_container_name"))}, + {"Container Port Name", template.HTML(target.Labels.Get("__meta_kubernetes_pod_container_port_name"))}, + {"Node Name", template.HTML(target.GetNodeName())}, + {"Endpoint Slice Name", template.HTML(target.GetEndpointSliceName())}, + }, + }) + WriteHTMLPropertiesTable(c.Writer, PropertiesTableData{ + Headers: []string{"Label", "Value"}, + Rows: func() [][]template.HTML { + var rows [][]template.HTML + for _, l := range target.Labels { + rows = append(rows, []template.HTML{template.HTML(l.Name), template.HTML(l.Value)}) + } + return rows + }(), + }) + WriteHTMLPageFooter(c.Writer) } -func jobsAnchorLink() string { +func jobsAnchorLink() template.HTML { return `Jobs` } @@ -478,45 +449,36 @@ func (s *Server) JobsHTMLHandler(c *gin.Context) { c.Writer.Header().Set("X-Content-Type-Options", "nosniff") c.Writer.Header().Set("Content-Type", "text/html; charset=utf-8") - var b bytes.Buffer - b.WriteString(` - -

Jobs

- -`) - fmt.Fprint(&b, header("Job", "Target Count")) - - jobs := make(map[string]int) - for _, v := range s.allocator.TargetItems() { - jobs[v.JobName]++ - } - - // Sort the jobs by name to ensure consistent order - jobNames := make([]string, 0, len(jobs)) - for k := range jobs { - jobNames = append(jobNames, k) - } - sort.Strings(jobNames) - - for _, j := range jobNames { - fmt.Fprint(&b, row(jobAnchorLink(j), strconv.Itoa(jobs[j]))) - } - - b.WriteString(`
- -`) - - _, err := c.Writer.Write(b.Bytes()) - if err != nil { - s.logger.Error(err, "failed to write response") - c.Status(http.StatusInternalServerError) - } + WriteHTMLPageHeader(c.Writer, HeaderData{ + Title: "OpenTelemetry Target Allocator - Jobs", + }) + WriteHTMLPropertiesTable(c.Writer, PropertiesTableData{ + Headers: []string{"Job", "Target Count"}, + Rows: func() [][]template.HTML { + var rows [][]template.HTML + jobs := make(map[string]int) + for _, v := range s.allocator.TargetItems() { + jobs[v.JobName]++ + } + // Sort the jobs by name to ensure consistent order + jobNames := make([]string, 0, len(jobs)) + for k := range jobs { + jobNames = append(jobNames, k) + } + sort.Strings(jobNames) - c.Status(http.StatusOK) + for _, j := range jobNames { + v := jobs[j] + rows = append(rows, []template.HTML{jobAnchorLink(j), template.HTML(strconv.Itoa(v))}) + } + return rows + }(), + }) + WriteHTMLPageFooter(c.Writer) } -func jobAnchorLink(jobId string) string { - return fmt.Sprintf("%s", jobId, jobId) +func jobAnchorLink(jobId string) template.HTML { + return template.HTML(fmt.Sprintf("%s", jobId, jobId)) } func (s *Server) JobHTMLHandler(c *gin.Context) { c.Writer.Header().Set("X-Content-Type-Options", "nosniff") @@ -529,71 +491,41 @@ func (s *Server) JobHTMLHandler(c *gin.Context) { } jobId := jobIdValues[0] - var b bytes.Buffer - t, err := template.New("job").Parse(` - -

Job: {{.}}

- -`) - if err != nil { - s.logger.Error(err, "failed to parse template") - return - } - err = t.Execute(&b, jobId) - if err != nil { - s.logger.Error(err, "failed to execute template") - return - } - fmt.Fprint(&b, header("Collector", "Target Count")) - - // Filter targets by job - targets := map[string]*target.Item{} - for k, v := range s.allocator.TargetItems() { - if v.JobName == jobId { - targets[k] = v - } - } - - colNames := []string{} - for _, col := range s.allocator.Collectors() { - colNames = append(colNames, col.Name) - } - sort.Strings(colNames) - - for _, colName := range colNames { - count := 0 - for _, target := range targets { - if target.CollectorName == colName { - count++ + WriteHTMLPageHeader(c.Writer, HeaderData{ + Title: "Job: " + jobId, + }) + WriteHTMLPropertiesTable(c.Writer, PropertiesTableData{ + Headers: []string{"Collector", "Target Count"}, + Rows: func() [][]template.HTML { + var rows [][]template.HTML + targets := map[string]*target.Item{} + for k, v := range s.allocator.TargetItems() { + if v.JobName == jobId { + targets[k] = v + } } - } - fmt.Fprint(&b, row(collectorAnchorLink(colName), strconv.Itoa(count))) - } - b.WriteString(`
- -`) - fmt.Fprint(&b, header("Collector", "Target")) - for _, v := range colNames { - for _, t := range targets { - if t.CollectorName == v { - fmt.Fprint(&b, row(collectorAnchorLink(v), targetAnchorLink(t))) + collectorNames := []string{} + for _, v := range s.allocator.Collectors() { + collectorNames = append(collectorNames, v.Name) } - } - } - b.WriteString(`
- -`) - _, err = c.Writer.Write(b.Bytes()) - if err != nil { - s.logger.Error(err, "failed to write response") - c.Status(http.StatusInternalServerError) - } - - c.Status(http.StatusOK) + sort.Strings(collectorNames) + for _, colName := range collectorNames { + count := 0 + for _, target := range targets { + if target.CollectorName == colName { + count++ + } + } + rows = append(rows, []template.HTML{collectorAnchorLink(colName), template.HTML(strconv.Itoa(count))}) + } + return rows + }(), + }) + WriteHTMLPageFooter(c.Writer) } -func collectorAnchorLink(collectorId string) string { - return fmt.Sprintf("%s", collectorId, collectorId) +func collectorAnchorLink(collectorId string) template.HTML { + return template.HTML(fmt.Sprintf("%s", collectorId, collectorId)) } func (s *Server) CollectorHTMLHandler(c *gin.Context) { @@ -644,38 +576,72 @@ func (s *Server) CollectorHTMLHandler(c *gin.Context) { return } - var b bytes.Buffer - t, err := template.New("collector").Parse(` - -

Collector: {{.}}

- -`) - if err != nil { - s.logger.Error(err, "failed to parse template") - return - } - err = t.Execute(&b, collectorId) - if err != nil { - s.logger.Error(err, "failed to execute template") - return - } + WriteHTMLPageHeader(c.Writer, HeaderData{ + Title: "Collector: " + collectorId, + }) + WriteHTMLPropertiesTable(c.Writer, PropertiesTableData{ + Headers: []string{"Job", "Target", "Endpoint Slice"}, + Rows: func() [][]template.HTML { + var rows [][]template.HTML + for _, v := range s.sortedTargetItems() { + if v.CollectorName == collectorId { + rows = append(rows, []template.HTML{ + jobAnchorLink(v.JobName), + targetAnchorLink(v), + template.HTML(v.GetEndpointSliceName()), + }) + } + } + return rows + }(), + }) + WriteHTMLPageFooter(c.Writer) +} - fmt.Fprint(&b, header("Job", "Target", "Endpoint Slice")) - for _, v := range s.sortedTargetItems() { - if v.CollectorName == collectorId { - fmt.Fprint(&b, row(jobAnchorLink(v.JobName), targetAnchorLink(v), v.GetEndpointSliceName())) - } - } - b.WriteString(`
- -`) - _, err = c.Writer.Write(b.Bytes()) +func scrapeConfigAnchorLink() template.HTML { + return `Scrape Configs` +} +func (s *Server) ScrapeConfigsHTMLHandler(c *gin.Context) { + c.Writer.Header().Set("X-Content-Type-Options", "nosniff") + c.Writer.Header().Set("Content-Type", "text/html; charset=utf-8") + + WriteHTMLPageHeader(c.Writer, HeaderData{ + Title: "OpenTelemetry Target Allocator - Scrape Configs", + }) + //s.scrapeConfigResponse + // Marshal the scrape config to JSON + scrapeConfigs := make(map[string]interface{}) + err := json.Unmarshal(s.scrapeConfigResponse, &scrapeConfigs) if err != nil { - s.logger.Error(err, "failed to write response") - c.Status(http.StatusInternalServerError) + s.errorHandler(c.Writer, err) + return } - - c.Status(http.StatusOK) + // Display the JSON in a table + + WriteHTMLPropertiesTable(c.Writer, PropertiesTableData{ + Headers: []string{"Job", "Scrape Config"}, + Rows: func() [][]template.HTML { + var rows [][]template.HTML + for job, scrapeConfig := range scrapeConfigs { + scrapeConfigJSON, err := json.Marshal(scrapeConfig) + if err != nil { + s.errorHandler(c.Writer, err) + return nil + } + // pretty print the JSON + scrapeConfigJSON, err = json.MarshalIndent(scrapeConfig, "", " ") + if err != nil { + s.errorHandler(c.Writer, err) + return nil + } + // Wrap the JSON in a
 tag to preserve formatting
+				scrapeConfigJSON = []byte(fmt.Sprintf("
%s
", scrapeConfigJSON)) + rows = append(rows, []template.HTML{template.HTML(jobAnchorLink(job)), template.HTML(scrapeConfigJSON)}) + } + return rows + }(), + }) + WriteHTMLPageFooter(c.Writer) } func (s *Server) TargetsHandler(c *gin.Context) { @@ -727,6 +693,15 @@ func (s *Server) sortedTargetItems() []*target.Item { return targetItems } +func (s *Server) getScrapeConfigCount() int { + scrapeConfigs := make(map[string]interface{}) + err := json.Unmarshal(s.scrapeConfigResponse, &scrapeConfigs) + if err != nil { + return 0 + } + return len(scrapeConfigs) +} + func (s *Server) getJobCount() int { jobs := make(map[string]struct{}) for _, v := range s.allocator.TargetItems() { diff --git a/cmd/otel-allocator/internal/server/server_test.go b/cmd/otel-allocator/internal/server/server_test.go index ef0280d160..ccdd9c5061 100644 --- a/cmd/otel-allocator/internal/server/server_test.go +++ b/cmd/otel-allocator/internal/server/server_test.go @@ -11,7 +11,6 @@ import ( "net/http" "net/http/httptest" "net/url" - "strings" "testing" "time" @@ -23,6 +22,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gopkg.in/yaml.v2" + "gotest.tools/v3/golden" logf "sigs.k8s.io/controller-runtime/pkg/log" "github.com/open-telemetry/opentelemetry-operator/cmd/otel-allocator/internal/allocation" @@ -613,33 +613,19 @@ func TestServer_JobsHandler_HTML(t *testing.T) { description string targetItems map[string]*target.Item expectedCode int - expectedJobs string + Golden string }{ { description: "nil jobs", targetItems: nil, expectedCode: http.StatusOK, - expectedJobs: ` - -

Jobs

- - -
JobTarget Count
- -`, + Golden: "jobs_empty.html", }, { description: "empty jobs", targetItems: map[string]*target.Item{}, expectedCode: http.StatusOK, - expectedJobs: ` - -

Jobs

- - -
JobTarget Count
- -`, + Golden: "jobs_empty.html", }, { description: "one job", @@ -647,15 +633,7 @@ func TestServer_JobsHandler_HTML(t *testing.T) { "targetitem": target.NewItem("job1", "", labels.Labels{}, ""), }, expectedCode: http.StatusOK, - expectedJobs: ` - -

Jobs

- - - -
JobTarget Count
job11
- -`, + Golden: "jobs_one.html", }, { description: "multiple jobs", @@ -666,17 +644,7 @@ func TestServer_JobsHandler_HTML(t *testing.T) { "d": target.NewItem("job3", "1.1.1.4:8080", labels.Labels{}, ""), "e": target.NewItem("job3", "1.1.1.5:8080", labels.Labels{}, "")}, expectedCode: http.StatusOK, - expectedJobs: ` - -

Jobs

- - - - - -
JobTarget Count
job11
job21
job33
- -`, + Golden: "jobs_multiple.html", }, } for _, tc := range tests { @@ -698,7 +666,7 @@ func TestServer_JobsHandler_HTML(t *testing.T) { assert.Equal(t, tc.expectedCode, result.StatusCode) bodyBytes, err := io.ReadAll(result.Body) require.NoError(t, err) - assert.Equal(t, tc.expectedJobs, string(bodyBytes)) + golden.Assert(t, string(bodyBytes), tc.Golden) }) } } @@ -710,14 +678,10 @@ func TestServer_JobHandler_HTML(t *testing.T) { cMap map[string]*target.Item allocator allocation.Allocator } - type want struct { - items string - errString string - } tests := []struct { - name string - args args - want want + name string + args args + Golden string }{ { name: "Empty target map", @@ -726,20 +690,7 @@ func TestServer_JobHandler_HTML(t *testing.T) { cMap: map[string]*target.Item{}, allocator: consistentHashing, }, - want: want{ - items: ` - -

Job: test-job

- - - - -
CollectorTarget Count
test-collector0
test-collector20
- - -
CollectorTarget
- -`}, + Golden: "job_empty.html", }, { name: "Single entry target map", @@ -750,22 +701,7 @@ func TestServer_JobHandler_HTML(t *testing.T) { }, allocator: consistentHashing, }, - want: want{ - items: ` - -

Job: test-job

- - - - -
CollectorTarget Count
test-collector0
test-collector21
- - - -
CollectorTarget
test-collector2test-url
- -`, - }, + Golden: "job_single.html", }, { name: "Multiple entry target map", @@ -777,22 +713,7 @@ func TestServer_JobHandler_HTML(t *testing.T) { }, allocator: consistentHashing, }, - want: want{ - items: ` - -

Job: test-job

- - - - -
CollectorTarget Count
test-collector0
test-collector21
- - - -
CollectorTarget
test-collector2test-url
- -`, - }, + Golden: "job_multiple.html", }, } for _, tt := range tests { @@ -815,11 +736,7 @@ func TestServer_JobHandler_HTML(t *testing.T) { body := result.Body bodyBytes, err := io.ReadAll(body) assert.NoError(t, err) - if len(tt.want.errString) != 0 { - assert.EqualError(t, err, tt.want.errString) - return - } - assert.Equal(t, tt.want.items, string(bodyBytes)) + golden.Assert(t, string(bodyBytes), tt.Golden) }) } } @@ -827,32 +744,16 @@ func TestServer_JobHandler_HTML(t *testing.T) { func TestServer_IndexHandler(t *testing.T) { allocator, _ := allocation.New("consistent-hashing", logger) tests := []struct { - description string - allocator allocation.Allocator - targetItems map[string]*target.Item - expectedHTML string + description string + allocator allocation.Allocator + targetItems map[string]*target.Item + Golden string }{ { description: "Empty target map", targetItems: map[string]*target.Item{}, allocator: allocator, - expectedHTML: strings.Trim(` - - -

OpenTelemetry Target Allocator

- - - - -
CategoryCount
Jobs0
Targets0
- - - - -
CollectorJob CountTarget Count
test-collector100
test-collector200
- - -`, "\n"), + Golden: "index_empty.html", }, { description: "Single entry target map", @@ -860,23 +761,7 @@ func TestServer_IndexHandler(t *testing.T) { baseTargetItem.Hash(): baseTargetItem, }, allocator: allocator, - expectedHTML: strings.Trim(` - - -

OpenTelemetry Target Allocator

- - - - -
CategoryCount
Jobs1
Targets1
- - - - -
CollectorJob CountTarget Count
test-collector111
test-collector200
- - -`, "\n"), + Golden: "index_single.html", }, { description: "Multiple entry target map", @@ -886,23 +771,7 @@ func TestServer_IndexHandler(t *testing.T) { testJobTwoTargetItemTwo.Hash(): testJobTwoTargetItemTwo, }, allocator: allocator, - expectedHTML: strings.Trim(` - - -

OpenTelemetry Target Allocator

- - - - -
CategoryCount
Jobs2
Targets3
- - - - -
CollectorJob CountTarget Count
test-collector122
test-collector211
- - -`, "\n"), + Golden: "index_multiple.html", }, } for _, tc := range tests { @@ -925,30 +794,23 @@ func TestServer_IndexHandler(t *testing.T) { body := result.Body bodyBytes, err := io.ReadAll(body) assert.NoError(t, err) - assert.Equal(t, tc.expectedHTML, string(bodyBytes)) + golden.Assert(t, string(bodyBytes), tc.Golden) }) } } func TestServer_TargetsHTMLHandler(t *testing.T) { allocator, _ := allocation.New("consistent-hashing", logger) tests := []struct { - description string - allocator allocation.Allocator - targetItems map[string]*target.Item - expectedHTML string + description string + allocator allocation.Allocator + targetItems map[string]*target.Item + Golden string }{ { description: "Empty target map", targetItems: map[string]*target.Item{}, allocator: allocator, - expectedHTML: ` - -

Targets

- - -
JobTargetCollectorEndpoint Slice
- -`, + Golden: "targets_empty.html", }, { description: "Single entry target map", @@ -956,15 +818,7 @@ func TestServer_TargetsHTMLHandler(t *testing.T) { baseTargetItem.Hash(): baseTargetItem, }, allocator: allocator, - expectedHTML: ` - -

Targets

- - - -
JobTargetCollectorEndpoint Slice
test-jobtest-urltest-collector1
- -`, + Golden: "targets_single.html", }, { description: "Multiple entry target map", @@ -974,17 +828,7 @@ func TestServer_TargetsHTMLHandler(t *testing.T) { testJobTwoTargetItemTwo.Hash(): testJobTwoTargetItemTwo, }, allocator: allocator, - expectedHTML: ` - -

Targets

- - - - - -
JobTargetCollectorEndpoint Slice
test-job2test-url3test-collector1
test-jobtest-url2test-collector2
test-jobtest-urltest-collector1
- -`, + Golden: "targets_multiple.html", }, } for _, tc := range tests { @@ -1007,7 +851,7 @@ func TestServer_TargetsHTMLHandler(t *testing.T) { body := result.Body bodyBytes, err := io.ReadAll(body) assert.NoError(t, err) - assert.Equal(t, tc.expectedHTML, string(bodyBytes)) + golden.Assert(t, string(bodyBytes), tc.Golden) }) } } @@ -1020,7 +864,7 @@ func TestServer_CollectorHandler(t *testing.T) { allocator allocation.Allocator targetItems map[string]*target.Item expectedCode int - expectedHTML string + Golden string }{ { description: "Empty target map", @@ -1028,14 +872,7 @@ func TestServer_CollectorHandler(t *testing.T) { targetItems: map[string]*target.Item{}, allocator: allocator, expectedCode: http.StatusOK, - expectedHTML: ` - -

Collector: test-collector

- - -
JobTargetEndpoint Slice
- -`, + Golden: "collector_empty.html", }, { description: "Single entry target map", @@ -1045,15 +882,7 @@ func TestServer_CollectorHandler(t *testing.T) { }, allocator: allocator, expectedCode: http.StatusOK, - expectedHTML: ` - -

Collector: test-collector2

- - - -
JobTargetEndpoint Slice
test-jobtest-url
- -`, + Golden: "collector_single.html", }, { description: "Multiple entry target map", @@ -1064,15 +893,7 @@ func TestServer_CollectorHandler(t *testing.T) { }, allocator: allocator, expectedCode: http.StatusOK, - expectedHTML: ` - -

Collector: test-collector2

- - - -
JobTargetEndpoint Slice
test-jobtest-url
- -`, + Golden: "collector_multiple.html", }, { description: "Multiple entry target map, collector id is empty", @@ -1083,13 +904,7 @@ func TestServer_CollectorHandler(t *testing.T) { }, allocator: allocator, expectedCode: http.StatusBadRequest, - expectedHTML: ` - -

Bad Request

-

Expected collector_id in the query string

-

Example: /collector?collector_id=my-collector-42

- -`, + Golden: "collector_empty_id.html", }, { description: "Multiple entry target map, unknown collector id", @@ -1100,11 +915,7 @@ func TestServer_CollectorHandler(t *testing.T) { }, allocator: allocator, expectedCode: http.StatusNotFound, - expectedHTML: ` - -

Unknown Collector: unknown-collector-1

- -`, + Golden: "collector_unknown_id.html", }, } for _, tc := range tests { @@ -1128,7 +939,7 @@ func TestServer_CollectorHandler(t *testing.T) { body := result.Body bodyBytes, err := io.ReadAll(body) assert.NoError(t, err) - assert.Equal(t, tc.expectedHTML, string(bodyBytes)) + golden.Assert(t, string(bodyBytes), tc.Golden) }) } } @@ -1141,7 +952,7 @@ func TestServer_TargetHTMLHandler(t *testing.T) { allocator allocation.Allocator targetItems map[string]*target.Item expectedCode int - expectedHTML string + Golden string }{ { description: "Missing target hash", @@ -1149,13 +960,7 @@ func TestServer_TargetHTMLHandler(t *testing.T) { targetItems: map[string]*target.Item{}, allocator: allocator, expectedCode: http.StatusBadRequest, - expectedHTML: ` - -

Bad Request

-

Expected target_hash in the query string

-

Example: /target?target_hash=my-target-42

- -`, + Golden: "target_empty_hash.html", }, { description: "Single entry target map", @@ -1165,20 +970,7 @@ func TestServer_TargetHTMLHandler(t *testing.T) { }, allocator: allocator, expectedCode: http.StatusOK, - expectedHTML: ` - -

Target: test-url

- - - -
Collectortest-collector2
Jobtest-job
-

Target Labels

- - - -
LabelValue
test_labeltest-value
- -`, + Golden: "target_single.html", }, { description: "Multiple entry target map", @@ -1189,20 +981,7 @@ func TestServer_TargetHTMLHandler(t *testing.T) { }, allocator: allocator, expectedCode: http.StatusOK, - expectedHTML: ` - -

Target: test-url3

- - - -
Collectortest-collector
Jobtest-job2
-

Target Labels

- - - -
LabelValue
test_labeltest-value2
- -`, + Golden: "target_multiple.html", }, } for _, tc := range tests { @@ -1226,7 +1005,7 @@ func TestServer_TargetHTMLHandler(t *testing.T) { body := result.Body bodyBytes, err := io.ReadAll(body) assert.NoError(t, err) - assert.Equal(t, tc.expectedHTML, string(bodyBytes)) + golden.Assert(t, string(bodyBytes), tc.Golden) }) } } @@ -1295,7 +1074,7 @@ func TestServer_Readiness(t *testing.T) { } } -func TestServer_ScrapeConfigRespose(t *testing.T) { +func TestServer_ScrapeConfigResponse(t *testing.T) { tests := []struct { description string filePath string diff --git a/cmd/otel-allocator/internal/server/templates.go b/cmd/otel-allocator/internal/server/templates.go new file mode 100644 index 0000000000..33c2880bb0 --- /dev/null +++ b/cmd/otel-allocator/internal/server/templates.go @@ -0,0 +1,69 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package server + +import ( + _ "embed" + "html/template" + "io" + "log" +) + +var ( + templateFunctions = template.FuncMap{ + "even": even, + } + + //go:embed templates/page_header.html + headerBytes []byte + headerTemplate = parseTemplate("header", headerBytes) + + //go:embed templates/page_footer.html + footerBytes []byte + footerTemplate = parseTemplate("footer", footerBytes) + + //go:embed templates/properties_table.html + propertiesTableBytes []byte + propertiesTableTemplate = parseTemplate("properties_table", propertiesTableBytes) +) + +func parseTemplate(name string, bytes []byte) *template.Template { + return template.Must(template.New(name).Funcs(templateFunctions).Parse(string(bytes))) +} + +// HeaderData contains data for the header template. +type HeaderData struct { + Title string +} + +// WriteHTMLPageHeader writes the header. +func WriteHTMLPageHeader(w io.Writer, hd HeaderData) { + if err := headerTemplate.Execute(w, hd); err != nil { + log.Printf("ta: executing template: %v", err) + } +} + +// PropertiesTableData contains data for properties table template. +type PropertiesTableData struct { + Headers []string + Rows [][]template.HTML +} + +// WriteHTMLPropertiesTable writes the HTML for properties table. +func WriteHTMLPropertiesTable(w io.Writer, chd PropertiesTableData) { + if err := propertiesTableTemplate.Execute(w, chd); err != nil { + log.Printf("ta: executing template: %v", err) + } +} + +// WriteHTMLPageFooter writes the footer. +func WriteHTMLPageFooter(w io.Writer) { + if err := footerTemplate.Execute(w, nil); err != nil { + log.Printf("ta: executing template: %v", err) + } +} + +func even(x int) bool { + return x%2 == 0 +} diff --git a/cmd/otel-allocator/internal/server/templates/page_footer.html b/cmd/otel-allocator/internal/server/templates/page_footer.html new file mode 100644 index 0000000000..691287b6e3 --- /dev/null +++ b/cmd/otel-allocator/internal/server/templates/page_footer.html @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/cmd/otel-allocator/internal/server/templates/page_header.html b/cmd/otel-allocator/internal/server/templates/page_header.html new file mode 100644 index 0000000000..3b0ca18a46 --- /dev/null +++ b/cmd/otel-allocator/internal/server/templates/page_header.html @@ -0,0 +1,16 @@ + + + + {{.Title}} + + + +

{{.Title}}

+ diff --git a/cmd/otel-allocator/internal/server/templates/properties_table.html b/cmd/otel-allocator/internal/server/templates/properties_table.html new file mode 100644 index 0000000000..2165764132 --- /dev/null +++ b/cmd/otel-allocator/internal/server/templates/properties_table.html @@ -0,0 +1,19 @@ + + + {{- range $index, $name := .Headers }} + + {{- end}} + + {{- $index := 0 }} + {{- range $index, $row := .Rows }} + + {{- range $index, $cell := $row}} + + {{- end}} + + {{- end}} +
+ {{$name}} +
+ {{$cell}} +
diff --git a/cmd/otel-allocator/internal/server/templates_test.go b/cmd/otel-allocator/internal/server/templates_test.go new file mode 100644 index 0000000000..5bd3b82ed1 --- /dev/null +++ b/cmd/otel-allocator/internal/server/templates_test.go @@ -0,0 +1,45 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package server + +import ( + "bytes" + "html/template" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const tmplBody = ` +

{{.Index|even}}

+` + +const want = ` +

true

+` + +type testFuncsInput struct { + Index int +} + +var tmpl = template.Must(template.New("countTest").Funcs(templateFunctions).Parse(tmplBody)) + +func TestTemplateFuncs(t *testing.T) { + buf := new(bytes.Buffer) + input := testFuncsInput{ + Index: 32, + } + require.NoError(t, tmpl.Execute(buf, input)) + assert.EqualValues(t, want, buf.String()) +} + +func TestNoCrash(t *testing.T) { + buf := new(bytes.Buffer) + assert.NotPanics(t, func() { WriteHTMLPageHeader(buf, HeaderData{Title: "Foo"}) }) + assert.NotPanics(t, func() { + WriteHTMLPropertiesTable(buf, PropertiesTableData{Headers: []string{"foo"}, Rows: [][]template.HTML{{"bar"}}}) + }) + assert.NotPanics(t, func() { WriteHTMLPageFooter(buf) }) +} diff --git a/cmd/otel-allocator/internal/server/testdata/collector_empty.html b/cmd/otel-allocator/internal/server/testdata/collector_empty.html new file mode 100644 index 0000000000..61f0dc788e --- /dev/null +++ b/cmd/otel-allocator/internal/server/testdata/collector_empty.html @@ -0,0 +1,31 @@ + + + + Collector: test-collector + + + +

Collector: test-collector

+ + + + + + + +
+ Job + + Target + + Endpoint Slice +
+ + \ No newline at end of file diff --git a/cmd/otel-allocator/internal/server/testdata/collector_empty_id.html b/cmd/otel-allocator/internal/server/testdata/collector_empty_id.html new file mode 100644 index 0000000000..1dd34d444a --- /dev/null +++ b/cmd/otel-allocator/internal/server/testdata/collector_empty_id.html @@ -0,0 +1,7 @@ + + +

Bad Request

+

Expected collector_id in the query string

+

Example: /collector?collector_id=my-collector-42

+ + \ No newline at end of file diff --git a/cmd/otel-allocator/internal/server/testdata/collector_multiple.html b/cmd/otel-allocator/internal/server/testdata/collector_multiple.html new file mode 100644 index 0000000000..49934f9012 --- /dev/null +++ b/cmd/otel-allocator/internal/server/testdata/collector_multiple.html @@ -0,0 +1,42 @@ + + + + Collector: test-collector2 + + + +

Collector: test-collector2

+ + + + + + + + + + + + +
+ Job + + Target + + Endpoint Slice +
+ test-job + + test-url + + +
+ + \ No newline at end of file diff --git a/cmd/otel-allocator/internal/server/testdata/collector_single.html b/cmd/otel-allocator/internal/server/testdata/collector_single.html new file mode 100644 index 0000000000..49934f9012 --- /dev/null +++ b/cmd/otel-allocator/internal/server/testdata/collector_single.html @@ -0,0 +1,42 @@ + + + + Collector: test-collector2 + + + +

Collector: test-collector2

+ + + + + + + + + + + + +
+ Job + + Target + + Endpoint Slice +
+ test-job + + test-url + + +
+ + \ No newline at end of file diff --git a/cmd/otel-allocator/internal/server/testdata/collector_unknown_id.html b/cmd/otel-allocator/internal/server/testdata/collector_unknown_id.html new file mode 100644 index 0000000000..edc8ee2cd2 --- /dev/null +++ b/cmd/otel-allocator/internal/server/testdata/collector_unknown_id.html @@ -0,0 +1,5 @@ + + +

Unknown Collector: unknown-collector-1

+ + \ No newline at end of file diff --git a/cmd/otel-allocator/internal/server/testdata/index_empty.html b/cmd/otel-allocator/internal/server/testdata/index_empty.html new file mode 100644 index 0000000000..4b2dc57692 --- /dev/null +++ b/cmd/otel-allocator/internal/server/testdata/index_empty.html @@ -0,0 +1,87 @@ + + + + OpenTelemetry Target Allocator + + + +

OpenTelemetry Target Allocator

+ + + + + + + + + + + + + + + + + + +
+ Category + + Count +
+ Scrape Configs + + 0 +
+ Jobs + + 0 +
+ Targets + + 0 +
+ + + + + + + + + + + + + + + + +
+ Collector + + Job Count + + Target Count +
+ test-collector1 + + 0 + + 0 +
+ test-collector2 + + 0 + + 0 +
+ + \ No newline at end of file diff --git a/cmd/otel-allocator/internal/server/testdata/index_multiple.html b/cmd/otel-allocator/internal/server/testdata/index_multiple.html new file mode 100644 index 0000000000..613e0845ab --- /dev/null +++ b/cmd/otel-allocator/internal/server/testdata/index_multiple.html @@ -0,0 +1,87 @@ + + + + OpenTelemetry Target Allocator + + + +

OpenTelemetry Target Allocator

+ + + + + + + + + + + + + + + + + + +
+ Category + + Count +
+ Scrape Configs + + 0 +
+ Jobs + + 2 +
+ Targets + + 3 +
+ + + + + + + + + + + + + + + + +
+ Collector + + Job Count + + Target Count +
+ test-collector1 + + 2 + + 2 +
+ test-collector2 + + 1 + + 1 +
+ + \ No newline at end of file diff --git a/cmd/otel-allocator/internal/server/testdata/index_single.html b/cmd/otel-allocator/internal/server/testdata/index_single.html new file mode 100644 index 0000000000..1ae386d745 --- /dev/null +++ b/cmd/otel-allocator/internal/server/testdata/index_single.html @@ -0,0 +1,87 @@ + + + + OpenTelemetry Target Allocator + + + +

OpenTelemetry Target Allocator

+ + + + + + + + + + + + + + + + + + +
+ Category + + Count +
+ Scrape Configs + + 0 +
+ Jobs + + 1 +
+ Targets + + 1 +
+ + + + + + + + + + + + + + + + +
+ Collector + + Job Count + + Target Count +
+ test-collector1 + + 1 + + 1 +
+ test-collector2 + + 0 + + 0 +
+ + \ No newline at end of file diff --git a/cmd/otel-allocator/internal/server/testdata/job_empty.html b/cmd/otel-allocator/internal/server/testdata/job_empty.html new file mode 100644 index 0000000000..7857dbc7ae --- /dev/null +++ b/cmd/otel-allocator/internal/server/testdata/job_empty.html @@ -0,0 +1,44 @@ + + + + Job: test-job + + + +

Job: test-job

+ + + + + + + + + + + + + + +
+ Collector + + Target Count +
+ test-collector + + 0 +
+ test-collector2 + + 0 +
+ + \ No newline at end of file diff --git a/cmd/otel-allocator/internal/server/testdata/job_multiple.html b/cmd/otel-allocator/internal/server/testdata/job_multiple.html new file mode 100644 index 0000000000..b8904bc48b --- /dev/null +++ b/cmd/otel-allocator/internal/server/testdata/job_multiple.html @@ -0,0 +1,44 @@ + + + + Job: test-job + + + +

Job: test-job

+ + + + + + + + + + + + + + +
+ Collector + + Target Count +
+ test-collector + + 0 +
+ test-collector2 + + 1 +
+ + \ No newline at end of file diff --git a/cmd/otel-allocator/internal/server/testdata/job_single.html b/cmd/otel-allocator/internal/server/testdata/job_single.html new file mode 100644 index 0000000000..b8904bc48b --- /dev/null +++ b/cmd/otel-allocator/internal/server/testdata/job_single.html @@ -0,0 +1,44 @@ + + + + Job: test-job + + + +

Job: test-job

+ + + + + + + + + + + + + + +
+ Collector + + Target Count +
+ test-collector + + 0 +
+ test-collector2 + + 1 +
+ + \ No newline at end of file diff --git a/cmd/otel-allocator/internal/server/testdata/jobs_empty.html b/cmd/otel-allocator/internal/server/testdata/jobs_empty.html new file mode 100644 index 0000000000..cf250a5512 --- /dev/null +++ b/cmd/otel-allocator/internal/server/testdata/jobs_empty.html @@ -0,0 +1,28 @@ + + + + OpenTelemetry Target Allocator - Jobs + + + +

OpenTelemetry Target Allocator - Jobs

+ + + + + + +
+ Job + + Target Count +
+ + \ No newline at end of file diff --git a/cmd/otel-allocator/internal/server/testdata/jobs_multiple.html b/cmd/otel-allocator/internal/server/testdata/jobs_multiple.html new file mode 100644 index 0000000000..7552a9461a --- /dev/null +++ b/cmd/otel-allocator/internal/server/testdata/jobs_multiple.html @@ -0,0 +1,52 @@ + + + + OpenTelemetry Target Allocator - Jobs + + + +

OpenTelemetry Target Allocator - Jobs

+ + + + + + + + + + + + + + + + + + +
+ Job + + Target Count +
+ job1 + + 1 +
+ job2 + + 1 +
+ job3 + + 3 +
+ + \ No newline at end of file diff --git a/cmd/otel-allocator/internal/server/testdata/jobs_one.html b/cmd/otel-allocator/internal/server/testdata/jobs_one.html new file mode 100644 index 0000000000..0de64b76c2 --- /dev/null +++ b/cmd/otel-allocator/internal/server/testdata/jobs_one.html @@ -0,0 +1,36 @@ + + + + OpenTelemetry Target Allocator - Jobs + + + +

OpenTelemetry Target Allocator - Jobs

+ + + + + + + + + + +
+ Job + + Target Count +
+ job1 + + 1 +
+ + \ No newline at end of file diff --git a/cmd/otel-allocator/internal/server/testdata/target_empty_hash.html b/cmd/otel-allocator/internal/server/testdata/target_empty_hash.html new file mode 100644 index 0000000000..73b99e1a9c --- /dev/null +++ b/cmd/otel-allocator/internal/server/testdata/target_empty_hash.html @@ -0,0 +1,7 @@ + + +

Bad Request

+

Expected target_hash in the query string

+

Example: /target?target_hash=my-target-42

+ + \ No newline at end of file diff --git a/cmd/otel-allocator/internal/server/testdata/target_multiple.html b/cmd/otel-allocator/internal/server/testdata/target_multiple.html new file mode 100644 index 0000000000..27fe0b6869 --- /dev/null +++ b/cmd/otel-allocator/internal/server/testdata/target_multiple.html @@ -0,0 +1,126 @@ + + + + Target: test-url3 + + + +

Target: test-url3

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + +
+ Collector + + test-collector +
+ Job + + test-job2 +
+ Namespace + + +
+ Service Name + + +
+ Service Port + + +
+ Pod Name + + +
+ Container Name + + +
+ Container Port Name + + +
+ Node Name + + +
+ Endpoint Slice Name + + +
+ + + + + + + + + +
+ Label + + Value +
+ test_label + + test-value2 +
+ + \ No newline at end of file diff --git a/cmd/otel-allocator/internal/server/testdata/target_single.html b/cmd/otel-allocator/internal/server/testdata/target_single.html new file mode 100644 index 0000000000..b856ed94f3 --- /dev/null +++ b/cmd/otel-allocator/internal/server/testdata/target_single.html @@ -0,0 +1,126 @@ + + + + Target: test-url + + + +

Target: test-url

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + +
+ Collector + + test-collector2 +
+ Job + + test-job +
+ Namespace + + +
+ Service Name + + +
+ Service Port + + +
+ Pod Name + + +
+ Container Name + + +
+ Container Port Name + + +
+ Node Name + + +
+ Endpoint Slice Name + + +
+ + + + + + + + + +
+ Label + + Value +
+ test_label + + test-value +
+ + \ No newline at end of file diff --git a/cmd/otel-allocator/internal/server/testdata/targets_empty.html b/cmd/otel-allocator/internal/server/testdata/targets_empty.html new file mode 100644 index 0000000000..b3fc12b223 --- /dev/null +++ b/cmd/otel-allocator/internal/server/testdata/targets_empty.html @@ -0,0 +1,34 @@ + + + + OpenTelemetry Target Allocator - Targets + + + +

OpenTelemetry Target Allocator - Targets

+ + + + + + + + +
+ Job + + Target + + Collector + + Endpoint Slice +
+ + \ No newline at end of file diff --git a/cmd/otel-allocator/internal/server/testdata/targets_multiple.html b/cmd/otel-allocator/internal/server/testdata/targets_multiple.html new file mode 100644 index 0000000000..bea3b53841 --- /dev/null +++ b/cmd/otel-allocator/internal/server/testdata/targets_multiple.html @@ -0,0 +1,76 @@ + + + + OpenTelemetry Target Allocator - Targets + + + +

OpenTelemetry Target Allocator - Targets

+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Job + + Target + + Collector + + Endpoint Slice +
+ test-job2 + + test-url3 + + test-collector1 + + +
+ test-job + + test-url2 + + test-collector2 + + +
+ test-job + + test-url + + test-collector1 + + +
+ + \ No newline at end of file diff --git a/cmd/otel-allocator/internal/server/testdata/targets_single.html b/cmd/otel-allocator/internal/server/testdata/targets_single.html new file mode 100644 index 0000000000..d98aadfa4d --- /dev/null +++ b/cmd/otel-allocator/internal/server/testdata/targets_single.html @@ -0,0 +1,48 @@ + + + + OpenTelemetry Target Allocator - Targets + + + +

OpenTelemetry Target Allocator - Targets

+ + + + + + + + + + + + + + +
+ Job + + Target + + Collector + + Endpoint Slice +
+ test-job + + test-url + + test-collector1 + + +
+ + \ No newline at end of file diff --git a/go.mod b/go.mod index 7a31a4b992..fa294431d0 100644 --- a/go.mod +++ b/go.mod @@ -228,6 +228,8 @@ require ( sigs.k8s.io/structured-merge-diff/v4 v4.5.0 // indirect ) +require gotest.tools/v3 v3.5.1 + require ( github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect github.com/google/btree v1.1.3 // indirect diff --git a/scripts/update-golden-files.sh b/scripts/update-golden-files.sh new file mode 100755 index 0000000000..029245d440 --- /dev/null +++ b/scripts/update-golden-files.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# This script updates the golden files for unit tests that import the 'gotest.tools/v3/golden' dependency in a Go project. +# It lists all packages in the project, checks for the dependency in test imports, and runs unit tests with '-update' to update golden files. + +dependency="gotest.tools/v3/golden" + +# List all packages in the project +packages=$(go list ./...) + +# Loop through each package and check if it imports the specific dependency +for pkg in $packages; do + # Use 'go list' with 'XTestImports' template to get the imports from test binaries + imports=$(go list -f '{{join .TestImports "\n"}}{{"\n"}}{{join .XTestImports "\n"}}' "$pkg") + + # Check if the dependency is in the imports + if echo "$imports" | grep -q "$dependency"; then + # If the dependency is found, run the unit tests updating the golden files + go test "$pkg" -update -timeout 30s + fi +done From 1e01164951403862f27995202d3932bb312c45a1 Mon Sep 17 00:00:00 2001 From: Charlie Le Date: Sun, 16 Mar 2025 15:08:44 -0700 Subject: [PATCH 3/3] Fix test lint Signed-off-by: Charlie Le --- cmd/otel-allocator/internal/server/server.go | 135 +++++++++--------- .../internal/server/templates.go | 20 ++- .../server/templates/properties_table.html | 8 +- .../internal/server/templates_test.go | 2 +- 4 files changed, 96 insertions(+), 69 deletions(-) diff --git a/cmd/otel-allocator/internal/server/server.go b/cmd/otel-allocator/internal/server/server.go index 688d215d77..60f73f1dde 100644 --- a/cmd/otel-allocator/internal/server/server.go +++ b/cmd/otel-allocator/internal/server/server.go @@ -285,14 +285,6 @@ func (s *Server) PrometheusMiddleware(c *gin.Context) { timer.ObserveDuration() } -func header(data ...string) string { - return "" + strings.Join(data, "") + "\n" -} - -func row(data ...string) string { - return "" + strings.Join(data, "") + "\n" -} - // IndexHandler displays the main page of the allocator. It shows the number of jobs and targets. // It also displays a table with the collectors and the number of jobs and targets for each collector. // The collector names are links to the respective pages. The table is sorted by collector name. @@ -304,16 +296,16 @@ func (s *Server) IndexHandler(c *gin.Context) { WriteHTMLPropertiesTable(c.Writer, PropertiesTableData{ Headers: []string{"Category", "Count"}, - Rows: [][]template.HTML{ - {scrapeConfigAnchorLink(), template.HTML(strconv.Itoa(s.getScrapeConfigCount()))}, - {jobsAnchorLink(), template.HTML(strconv.Itoa(s.getJobCount()))}, - {targetsAnchorLink(), template.HTML(strconv.Itoa(len(s.allocator.TargetItems())))}, + Rows: [][]Cell{ + {scrapeConfigAnchorLink(), Text(strconv.Itoa(s.getScrapeConfigCount()))}, + {jobsAnchorLink(), Text(strconv.Itoa(s.getJobCount()))}, + {targetsAnchorLink(), Text(strconv.Itoa(len(s.allocator.TargetItems())))}, }, }) WriteHTMLPropertiesTable(c.Writer, PropertiesTableData{ Headers: []string{"Collector", "Job Count", "Target Count"}, - Rows: func() [][]template.HTML { - var rows [][]template.HTML + Rows: func() [][]Cell { + var rows [][]Cell collectorNames := []string{} for k := range s.allocator.Collectors() { collectorNames = append(collectorNames, k) @@ -323,7 +315,7 @@ func (s *Server) IndexHandler(c *gin.Context) { for _, colName := range collectorNames { jobCount := strconv.Itoa(s.getJobCountForCollector(colName)) targetCount := strconv.Itoa(s.getTargetCountForCollector(colName)) - rows = append(rows, []template.HTML{collectorAnchorLink(colName), template.HTML(jobCount), template.HTML(targetCount)}) + rows = append(rows, []Cell{collectorAnchorLink(colName), NewCell(jobCount), NewCell(targetCount)}) } return rows }(), @@ -331,8 +323,11 @@ func (s *Server) IndexHandler(c *gin.Context) { WriteHTMLPageFooter(c.Writer) } -func targetsAnchorLink() template.HTML { - return `Targets` +func targetsAnchorLink() Cell { + return Cell{ + Link: "/targets", + Text: "Targets", + } } // TargetsHTMLHandler displays the targets in a table format. Each target is a row in the table. @@ -348,14 +343,14 @@ func (s *Server) TargetsHTMLHandler(c *gin.Context) { WriteHTMLPropertiesTable(c.Writer, PropertiesTableData{ Headers: []string{"Job", "Target", "Collector", "Endpoint Slice"}, - Rows: func() [][]template.HTML { - var rows [][]template.HTML + Rows: func() [][]Cell { + var rows [][]Cell for _, v := range s.sortedTargetItems() { - rows = append(rows, []template.HTML{ + rows = append(rows, []Cell{ jobAnchorLink(v.JobName), targetAnchorLink(v), collectorAnchorLink(v.CollectorName), - template.HTML(v.GetEndpointSliceName()), + NewCell(v.GetEndpointSliceName()), }) } return rows @@ -364,8 +359,11 @@ func (s *Server) TargetsHTMLHandler(c *gin.Context) { WriteHTMLPageFooter(c.Writer) } -func targetAnchorLink(t *target.Item) template.HTML { - return template.HTML(fmt.Sprintf("%s", t.Hash(), t.TargetURL)) +func targetAnchorLink(t *target.Item) Cell { + return Cell{ + Link: fmt.Sprintf("/target?target_hash=%s", t.Hash()), + Text: t.TargetURL, + } } // TargetHTMLHandler displays information about a target in a table format. @@ -413,25 +411,25 @@ func (s *Server) TargetHTMLHandler(c *gin.Context) { }) WriteHTMLPropertiesTable(c.Writer, PropertiesTableData{ Headers: []string{"", ""}, - Rows: [][]template.HTML{ - {"Collector", collectorAnchorLink(target.CollectorName)}, - {"Job", jobAnchorLink(target.JobName)}, - {"Namespace", template.HTML(target.Labels.Get("__meta_kubernetes_namespace"))}, - {"Service Name", template.HTML(target.Labels.Get("__meta_kubernetes_service_name"))}, - {"Service Port", template.HTML(target.Labels.Get("__meta_kubernetes_service_port"))}, - {"Pod Name", template.HTML(target.Labels.Get("__meta_kubernetes_pod_name"))}, - {"Container Name", template.HTML(target.Labels.Get("__meta_kubernetes_pod_container_name"))}, - {"Container Port Name", template.HTML(target.Labels.Get("__meta_kubernetes_pod_container_port_name"))}, - {"Node Name", template.HTML(target.GetNodeName())}, - {"Endpoint Slice Name", template.HTML(target.GetEndpointSliceName())}, + Rows: [][]Cell{ + {NewCell("Collector"), collectorAnchorLink(target.CollectorName)}, + {NewCell("Job"), jobAnchorLink(target.JobName)}, + {NewCell("Namespace"), NewCell(target.Labels.Get("__meta_kubernetes_namespace"))}, + {NewCell("Service Name"), NewCell(target.Labels.Get("__meta_kubernetes_service_name"))}, + {NewCell("Service Port"), NewCell(target.Labels.Get("__meta_kubernetes_service_port"))}, + {NewCell("Pod Name"), NewCell(target.Labels.Get("__meta_kubernetes_pod_name"))}, + {NewCell("Container Name"), NewCell(target.Labels.Get("__meta_kubernetes_pod_container_name"))}, + {NewCell("Container Port Name"), NewCell(target.Labels.Get("__meta_kubernetes_pod_container_port_name"))}, + {NewCell("Node Name"), NewCell(target.GetNodeName())}, + {NewCell("Endpoint Slice Name"), NewCell(target.GetEndpointSliceName())}, }, }) WriteHTMLPropertiesTable(c.Writer, PropertiesTableData{ Headers: []string{"Label", "Value"}, - Rows: func() [][]template.HTML { - var rows [][]template.HTML + Rows: func() [][]Cell { + var rows [][]Cell for _, l := range target.Labels { - rows = append(rows, []template.HTML{template.HTML(l.Name), template.HTML(l.Value)}) + rows = append(rows, []Cell{NewCell(l.Name), NewCell(l.Value)}) } return rows }(), @@ -439,8 +437,11 @@ func (s *Server) TargetHTMLHandler(c *gin.Context) { WriteHTMLPageFooter(c.Writer) } -func jobsAnchorLink() template.HTML { - return `Jobs` +func jobsAnchorLink() Cell { + return Cell{ + Link: "/jobs", + Text: "Jobs", + } } // JobsHTMLHandler displays the jobs in a table format. Each job is a row in the table. @@ -454,8 +455,8 @@ func (s *Server) JobsHTMLHandler(c *gin.Context) { }) WriteHTMLPropertiesTable(c.Writer, PropertiesTableData{ Headers: []string{"Job", "Target Count"}, - Rows: func() [][]template.HTML { - var rows [][]template.HTML + Rows: func() [][]Cell { + var rows [][]Cell jobs := make(map[string]int) for _, v := range s.allocator.TargetItems() { jobs[v.JobName]++ @@ -469,7 +470,7 @@ func (s *Server) JobsHTMLHandler(c *gin.Context) { for _, j := range jobNames { v := jobs[j] - rows = append(rows, []template.HTML{jobAnchorLink(j), template.HTML(strconv.Itoa(v))}) + rows = append(rows, []Cell{jobAnchorLink(j), NewCell(strconv.Itoa(v))}) } return rows }(), @@ -477,8 +478,11 @@ func (s *Server) JobsHTMLHandler(c *gin.Context) { WriteHTMLPageFooter(c.Writer) } -func jobAnchorLink(jobId string) template.HTML { - return template.HTML(fmt.Sprintf("%s", jobId, jobId)) +func jobAnchorLink(jobId string) Cell { + return Cell{ + Link: fmt.Sprintf("/job?job_id=%s", url.QueryEscape(jobId)), + Text: jobId, + } } func (s *Server) JobHTMLHandler(c *gin.Context) { c.Writer.Header().Set("X-Content-Type-Options", "nosniff") @@ -496,8 +500,8 @@ func (s *Server) JobHTMLHandler(c *gin.Context) { }) WriteHTMLPropertiesTable(c.Writer, PropertiesTableData{ Headers: []string{"Collector", "Target Count"}, - Rows: func() [][]template.HTML { - var rows [][]template.HTML + Rows: func() [][]Cell { + var rows [][]Cell targets := map[string]*target.Item{} for k, v := range s.allocator.TargetItems() { if v.JobName == jobId { @@ -516,7 +520,7 @@ func (s *Server) JobHTMLHandler(c *gin.Context) { count++ } } - rows = append(rows, []template.HTML{collectorAnchorLink(colName), template.HTML(strconv.Itoa(count))}) + rows = append(rows, []Cell{collectorAnchorLink(colName), NewCell(strconv.Itoa(count))}) } return rows }(), @@ -524,8 +528,11 @@ func (s *Server) JobHTMLHandler(c *gin.Context) { WriteHTMLPageFooter(c.Writer) } -func collectorAnchorLink(collectorId string) template.HTML { - return template.HTML(fmt.Sprintf("%s", collectorId, collectorId)) +func collectorAnchorLink(collectorId string) Cell { + return Cell{ + Link: fmt.Sprintf("/collector?collector_id=%s", url.QueryEscape(collectorId)), + Text: collectorId, + } } func (s *Server) CollectorHTMLHandler(c *gin.Context) { @@ -581,14 +588,14 @@ func (s *Server) CollectorHTMLHandler(c *gin.Context) { }) WriteHTMLPropertiesTable(c.Writer, PropertiesTableData{ Headers: []string{"Job", "Target", "Endpoint Slice"}, - Rows: func() [][]template.HTML { - var rows [][]template.HTML + Rows: func() [][]Cell { + var rows [][]Cell for _, v := range s.sortedTargetItems() { if v.CollectorName == collectorId { - rows = append(rows, []template.HTML{ + rows = append(rows, []Cell{ jobAnchorLink(v.JobName), targetAnchorLink(v), - template.HTML(v.GetEndpointSliceName()), + NewCell(v.GetEndpointSliceName()), }) } } @@ -598,8 +605,11 @@ func (s *Server) CollectorHTMLHandler(c *gin.Context) { WriteHTMLPageFooter(c.Writer) } -func scrapeConfigAnchorLink() template.HTML { - return `Scrape Configs` +func scrapeConfigAnchorLink() Cell { + return Cell{ + Link: "/scrape_configs", + Text: "Scrape Configs", + } } func (s *Server) ScrapeConfigsHTMLHandler(c *gin.Context) { c.Writer.Header().Set("X-Content-Type-Options", "nosniff") @@ -620,23 +630,16 @@ func (s *Server) ScrapeConfigsHTMLHandler(c *gin.Context) { WriteHTMLPropertiesTable(c.Writer, PropertiesTableData{ Headers: []string{"Job", "Scrape Config"}, - Rows: func() [][]template.HTML { - var rows [][]template.HTML + Rows: func() [][]Cell { + var rows [][]Cell for job, scrapeConfig := range scrapeConfigs { - scrapeConfigJSON, err := json.Marshal(scrapeConfig) - if err != nil { - s.errorHandler(c.Writer, err) - return nil - } // pretty print the JSON - scrapeConfigJSON, err = json.MarshalIndent(scrapeConfig, "", " ") + scrapeConfigJSON, err := json.MarshalIndent(scrapeConfig, "", " ") if err != nil { s.errorHandler(c.Writer, err) return nil } - // Wrap the JSON in a
 tag to preserve formatting
-				scrapeConfigJSON = []byte(fmt.Sprintf("
%s
", scrapeConfigJSON)) - rows = append(rows, []template.HTML{template.HTML(jobAnchorLink(job)), template.HTML(scrapeConfigJSON)}) + rows = append(rows, []Cell{jobAnchorLink(job), {Text: string(scrapeConfigJSON), Preformatted: true}}) } return rows }(), diff --git a/cmd/otel-allocator/internal/server/templates.go b/cmd/otel-allocator/internal/server/templates.go index 33c2880bb0..9c090fc3f2 100644 --- a/cmd/otel-allocator/internal/server/templates.go +++ b/cmd/otel-allocator/internal/server/templates.go @@ -47,7 +47,25 @@ func WriteHTMLPageHeader(w io.Writer, hd HeaderData) { // PropertiesTableData contains data for properties table template. type PropertiesTableData struct { Headers []string - Rows [][]template.HTML + Rows [][]Cell +} + +// Cell represents a cell in a row. +type Cell struct { + // Link is the URL to link to. If empty, no link is created. + Link string + // Text is the text to display in the cell. + Text string + // Preformatted indicates if the text should be displayed as preformatted text. + Preformatted bool +} + +func NewCell(text string) Cell { + return Cell{Text: text} +} + +func Text(text string) Cell { + return Cell{Text: text} } // WriteHTMLPropertiesTable writes the HTML for properties table. diff --git a/cmd/otel-allocator/internal/server/templates/properties_table.html b/cmd/otel-allocator/internal/server/templates/properties_table.html index 2165764132..a4abe9d80b 100644 --- a/cmd/otel-allocator/internal/server/templates/properties_table.html +++ b/cmd/otel-allocator/internal/server/templates/properties_table.html @@ -11,7 +11,13 @@ {{- range $index, $cell := $row}} - {{$cell}} + {{- if $cell.Link }} + {{$cell.Text}} + {{- else if $cell.Preformatted }} +
{{$cell.Text}}
+ {{- else}} + {{$cell.Text}} + {{- end}} {{- end}} diff --git a/cmd/otel-allocator/internal/server/templates_test.go b/cmd/otel-allocator/internal/server/templates_test.go index 5bd3b82ed1..8391b3ca67 100644 --- a/cmd/otel-allocator/internal/server/templates_test.go +++ b/cmd/otel-allocator/internal/server/templates_test.go @@ -39,7 +39,7 @@ func TestNoCrash(t *testing.T) { buf := new(bytes.Buffer) assert.NotPanics(t, func() { WriteHTMLPageHeader(buf, HeaderData{Title: "Foo"}) }) assert.NotPanics(t, func() { - WriteHTMLPropertiesTable(buf, PropertiesTableData{Headers: []string{"foo"}, Rows: [][]template.HTML{{"bar"}}}) + WriteHTMLPropertiesTable(buf, PropertiesTableData{Headers: []string{"foo"}, Rows: [][]Cell{{NewCell("bar")}}}) }) assert.NotPanics(t, func() { WriteHTMLPageFooter(buf) }) }