moar ux
Jes Olson j3s@c3f.net
Sat, 23 May 2026 14:20:14 -0500
7 files changed,
85 insertions(+),
32 deletions(-)
M
api/catalog.go
→
api/catalog.go
@@ -70,6 +70,23 @@ }
return out, nil } +// Instances returns every gossipped service instance across the cluster. +// One round trip — useful when you want to render a per-node view rather +// than per-service. +func (cat *Catalog) Instances() ([]*CatalogService, error) { + r := cat.c.newRequest("GET", "/v1/catalog/instances") + _, resp, err := requireOK(cat.c.doRequest(r)) + if err != nil { + return nil, err + } + defer closeResponseBody(resp) + var out []*CatalogService + if err := decodeJSONBody(resp.Body, &out); err != nil { + return nil, err + } + return out, nil +} + // Nodes returns every node serf knows about. func (cat *Catalog) Nodes() ([]*Node, error) { r := cat.c.newRequest("GET", "/v1/catalog/nodes")
M
internal/agent/agent.go
→
internal/agent/agent.go
@@ -245,6 +245,12 @@ func (a *Agent) CatalogServiceInstances(name string) []NodeService {
return a.services.ServiceInstances(name) } +// CatalogAllInstances returns every gossipped service instance across the +// whole cluster as a flat slice. +func (a *Agent) CatalogAllInstances() []NodeService { + return a.services.AllInstances() +} + // CatalogNodeServices returns the services owned by the named node. func (a *Agent) CatalogNodeServices(node string) map[string]*api.AgentService { return a.services.NodeServices(node)
M
internal/agent/catalog_endpoint.go
→
internal/agent/catalog_endpoint.go
@@ -8,6 +8,29 @@
"j3s.sh/cascade/api" ) +// catalogInstances serves GET /v1/catalog/instances. Returns one entry +// per (node, service) pair across the whole cluster. +func (s *HTTPHandlers) catalogInstances(w http.ResponseWriter, r *http.Request) { + instances := s.agent.CatalogAllInstances() + out := make([]api.CatalogService, 0, len(instances)) + for _, inst := range instances { + entry := api.CatalogService{ + Node: inst.Node, + ServiceID: inst.Service.ID, + ServiceName: inst.Service.Service, + ServiceAddress: inst.Service.Address, + ServicePort: inst.Service.Port, + ServiceTags: inst.Service.Tags, + ServiceMeta: inst.Service.Meta, + } + if m, ok := s.agent.MemberByName(inst.Node); ok { + entry.Address = m.Addr.String() + } + out = append(out, entry) + } + s.writeJSON(w, r, out) +} + // catalogServices serves GET /v1/catalog/services. Returns the cluster-wide // map of service name -> union of tags seen on any instance. func (s *HTTPHandlers) catalogServices(w http.ResponseWriter, r *http.Request) {
M
internal/agent/http.go
→
internal/agent/http.go
@@ -117,6 +117,7 @@ "/v1/agent/service/register": s.agentServiceRegister,
"/v1/agent/service/deregister/": s.agentServiceDeregister, "/v1/catalog/services": s.catalogServices, "/v1/catalog/service/": s.catalogService, + "/v1/catalog/instances": s.catalogInstances, "/v1/catalog/nodes": s.catalogNodes, "/v1/catalog/node/": s.catalogNode, }
M
internal/agent/services.go
→
internal/agent/services.go
@@ -212,6 +212,27 @@ }
return out } +// AllInstances returns every gossipped service instance across the entire +// cluster, one entry per (node, service) pair. +func (s *ServiceStore) AllInstances() []NodeService { + s.mu.RLock() + defer s.mu.RUnlock() + var out []NodeService + for node, byID := range s.services { + for _, svc := range byID { + cp := *svc + out = append(out, NodeService{Node: node, Service: &cp}) + } + } + sort.Slice(out, func(i, j int) bool { + if out[i].Node != out[j].Node { + return out[i].Node < out[j].Node + } + return out[i].Service.ID < out[j].Service.ID + }) + return out +} + // ServiceInstances returns every gossipped instance of the named service, // across all nodes. func (s *ServiceStore) ServiceInstances(name string) []NodeService {
M
internal/cli/nodes.go
→
internal/cli/nodes.go
@@ -5,6 +5,7 @@ "bytes"
"flag" "fmt" "os" + "sort" "strings" "text/tabwriter"@@ -13,7 +14,6 @@ )
type nodesCommand struct { flagAPIAddr string - flagDetails bool } func (c nodesCommand) Usage() {@@ -30,8 +30,6 @@ func (c *nodesCommand) Init(args []string) {
flags := flag.NewFlagSet("", flag.ContinueOnError) flags.Usage = c.Usage flags.StringVar(&c.flagAPIAddr, "api", "", "") - flags.BoolVar(&c.flagDetails, "details", false, "") - flags.BoolVar(&c.flagDetails, "l", false, "") if err := flags.Parse(args); err != nil { fmt.Println(err) os.Exit(1)@@ -57,15 +55,25 @@ fmt.Println(err)
os.Exit(1) } + // Fetch the full instance list once; group by owning node. + instances, _ := client.Catalog().Instances() + servicesByNode := map[string][]string{} + for _, inst := range instances { + servicesByNode[inst.Node] = append(servicesByNode[inst.Node], inst.ServiceName) + } + for n := range servicesByNode { + sort.Strings(servicesByNode[n]) + } + var b bytes.Buffer tw := tabwriter.NewWriter(&b, 0, 2, 2, ' ', 0) - fmt.Fprintf(tw, "node\taddr\tstatus\ttags\n") + fmt.Fprintf(tw, "node\taddr\tstatus\tservices\n") for _, n := range nodes { - fmt.Fprintf(tw, "%s\t", n.Name) - fmt.Fprintf(tw, "%s:%d\t", n.Addr, n.Port) - fmt.Fprintf(tw, "%s\t", n.StatusPretty()) - fmt.Fprintf(tw, "%s\t", c.printTags(n.Tags)) - fmt.Fprintln(tw) + services := "<none>" + if names := servicesByNode[n.Name]; len(names) > 0 { + services = strings.Join(names, ", ") + } + fmt.Fprintf(tw, "%s\t%s:%d\t%s\t%s\n", n.Name, n.Addr, n.Port, n.StatusPretty(), services) } if err := tw.Flush(); err != nil { fmt.Printf("error flushing tabwriter: %s", err)@@ -73,14 +81,3 @@ os.Exit(1)
} fmt.Print(b.String()) } - -func (c nodesCommand) printTags(tags map[string]string) string { - var results []string - for k, v := range tags { - results = append(results, fmt.Sprintf("%s=%s", k, v)) - } - if len(results) == 0 { - return "<none>" - } - return strings.Join(results, ",") -}
M
internal/cli/status.go
→
internal/cli/status.go
@@ -74,12 +74,8 @@ dnsDomain = self.Config.DNSDomain
} statusCounts := map[string]int{} - tagKeyCounts := map[string]int{} for _, n := range nodes { statusCounts[n.StatusPretty()]++ - for k := range n.Tags { - tagKeyCounts[k]++ - } } localServices, _ := agent.Services()@@ -122,14 +118,6 @@
fmt.Fprintln(tw, "status") for _, s := range sortedKeys(statusCounts) { fmt.Fprintf(tw, " %s\t%d\n", s, statusCounts[s]) - } - - if len(tagKeyCounts) > 0 { - fmt.Fprintln(tw) - fmt.Fprintln(tw, "tags") - for _, k := range sortedKeys(tagKeyCounts) { - fmt.Fprintf(tw, " %s\t%d nodes\n", k, tagKeyCounts[k]) - } } if err := tw.Flush(); err != nil {