SPA (cmd/frontend/web): - New "lb buckets" column backed by a 1s-debounced GetVPPLBState fetch loop with leading+trailing edge coalesce. - Per-frontend health icon (✅/⚠️/❗/‼️/❓) in the Zippy header, gated by a settling flag that suppresses ‼️ until the next lb-state reconciliation after a backend transition or weight change. - In-place leaf merge on lb-state so stable bucket values (e.g. "0") don't retrigger the Flash animation on every refresh. - Zippy cards remember open state in a cookie, default closed on fresh load; fixed-width frontend-title-name + reserved icon slot so headers line up across all cards. - Clock-drift watchdog in sse.ts that forces a fresh EventSource on laptop-wake so the broker emits a resync instead of hanging on a dead half-open socket. Frontend service (cmd/frontend): - maglevClient.lbStateLoop, trigger on backend transitions + vpp-connect, best-effort fetch on refreshAll. - Admin handlers explicitly wake the lb-state loop after lifecycle ops and set-weight (the latter emits no transition event on the maglevd side, so the WatchEvents path wouldn't have caught it). - /favicon.ico served from embedded web/public IPng logo. VPP integration: - internal/vpp/lbstate.go: dumpASesForVIP drops Pfx from the dump request (setting it silently wipes IPv4 replies in the LB plugin) and filters results by prefix on the response side instead, which also demuxes multi-VIP-on-same-port cases correctly. maglevc: - Walk now returns the unconsumed token tail; dispatch and the question listener reject unknown commands with a targeted error instead of dumping the full command tree prefixed with garbage. - On '?', echo the current line (including the '?') before the help list so the output reads like birdc. Checker / prober: - internal/checker: ±10% jitter on NextInterval so probes across restart don't all fire on the same tick. - internal/prober: HTTP User-Agent now carries the build version and project URL. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
169 lines
4.7 KiB
Go
169 lines
4.7 KiB
Go
// Copyright (c) 2026, Pim van Pelt <pim@ipng.ch>
|
|
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
|
|
"git.ipng.ch/ipng/vpp-maglev/internal/grpcapi"
|
|
)
|
|
|
|
// Node is one word in the command tree. Leaf nodes have a Run function.
|
|
// Slot nodes have Dynamic set (and no fixed Word to match against); they
|
|
// accept any single token as an argument and may have further Children.
|
|
type Node struct {
|
|
Word string
|
|
Help string
|
|
Dynamic func(context.Context, grpcapi.MaglevClient) []string // non-nil → slot node
|
|
Children []*Node
|
|
Run func(context.Context, grpcapi.MaglevClient, []string) error
|
|
}
|
|
|
|
// Walk descends the tree following tokens. At each step it tries fixed
|
|
// children first (exact then prefix), then falls back to a slot child
|
|
// (Dynamic != nil). Tokens consumed by slot children are collected as args.
|
|
// Returns the deepest node reached, the args collected from slot nodes,
|
|
// and any tokens that could not be matched. A non-empty remaining slice
|
|
// means the input contained a token that neither matched a fixed child
|
|
// at the current node nor fed into a slot — callers should treat that
|
|
// as "unknown command" rather than silently anchoring help at the root.
|
|
func Walk(root *Node, tokens []string) (*Node, []string, []string) {
|
|
node := root
|
|
var args []string
|
|
for len(tokens) > 0 {
|
|
tok := tokens[0]
|
|
|
|
// Try fixed children (exact, then unique prefix).
|
|
next := matchFixedChild(node.Children, tok)
|
|
if next != nil {
|
|
node = next
|
|
tokens = tokens[1:]
|
|
continue
|
|
}
|
|
|
|
// Try a slot child.
|
|
slot := findSlotChild(node.Children)
|
|
if slot != nil {
|
|
args = append(args, tok)
|
|
tokens = tokens[1:]
|
|
node = slot
|
|
continue
|
|
}
|
|
|
|
// Dead end — no match. The caller gets the still-unconsumed tail
|
|
// in the third return value.
|
|
break
|
|
}
|
|
return node, args, tokens
|
|
}
|
|
|
|
// matchFixedChild returns the child matching tok by exact then unique prefix,
|
|
// considering only non-slot children.
|
|
func matchFixedChild(children []*Node, tok string) *Node {
|
|
var fixed []*Node
|
|
for _, c := range children {
|
|
if c.Dynamic == nil {
|
|
fixed = append(fixed, c)
|
|
}
|
|
}
|
|
// Exact match.
|
|
for _, c := range fixed {
|
|
if c.Word == tok {
|
|
return c
|
|
}
|
|
}
|
|
// Unique prefix match.
|
|
var matches []*Node
|
|
for _, c := range fixed {
|
|
if strings.HasPrefix(c.Word, tok) {
|
|
matches = append(matches, c)
|
|
}
|
|
}
|
|
if len(matches) == 1 {
|
|
return matches[0]
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// findSlotChild returns the first child that is a slot node (Dynamic != nil).
|
|
func findSlotChild(children []*Node) *Node {
|
|
for _, c := range children {
|
|
if c.Dynamic != nil {
|
|
return c
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// helpLine is a (path, help) pair used when displaying '?' output.
|
|
type helpLine struct {
|
|
path string
|
|
help string
|
|
}
|
|
|
|
// expandPaths returns all (path, help) pairs for every node reachable from
|
|
// node that has a Run function. prefix is the display string accumulated so
|
|
// far (e.g. "show frontend"). visited prevents infinite loops through
|
|
// self-referencing slot nodes like watchEventsOptSlot.
|
|
func expandPaths(node *Node, prefix string, visited map[*Node]bool) []helpLine {
|
|
if visited[node] {
|
|
return nil
|
|
}
|
|
visited[node] = true
|
|
|
|
var lines []helpLine
|
|
if node.Run != nil {
|
|
lines = append(lines, helpLine{path: prefix, help: node.Help})
|
|
}
|
|
for _, child := range node.Children {
|
|
childPrefix := child.Word
|
|
if prefix != "" {
|
|
childPrefix = prefix + " " + child.Word
|
|
}
|
|
lines = append(lines, expandPaths(child, childPrefix, visited)...)
|
|
}
|
|
return lines
|
|
}
|
|
|
|
// Candidates returns the completable children at the current position given
|
|
// the already-typed tokens and the partial token being completed.
|
|
func Candidates(root *Node, tokens []string, partial string, ctx context.Context, client grpcapi.MaglevClient) []*Node {
|
|
// Walk the already-confirmed tokens. If any of them are unknown,
|
|
// offer no completions at all — continuing to suggest children off
|
|
// the partially-walked node would mislead the user into "completing"
|
|
// an invalid command.
|
|
node, _, remaining := Walk(root, tokens)
|
|
if len(remaining) > 0 {
|
|
return nil
|
|
}
|
|
|
|
// Now look at what could follow at this node.
|
|
// Check fixed children filtered by partial.
|
|
fixedMatches := filterFixedChildren(node.Children, partial)
|
|
|
|
// Check dynamic slot child if present.
|
|
var dynMatches []*Node
|
|
slot := findSlotChild(node.Children)
|
|
if slot != nil && slot.Dynamic != nil {
|
|
vals := slot.Dynamic(ctx, client)
|
|
for _, v := range vals {
|
|
if strings.HasPrefix(v, partial) {
|
|
dynMatches = append(dynMatches, &Node{Word: v, Help: slot.Help})
|
|
}
|
|
}
|
|
}
|
|
|
|
return append(fixedMatches, dynMatches...)
|
|
}
|
|
|
|
func filterFixedChildren(children []*Node, prefix string) []*Node {
|
|
var out []*Node
|
|
for _, c := range children {
|
|
if c.Dynamic == nil && strings.HasPrefix(c.Word, prefix) {
|
|
out = append(out, c)
|
|
}
|
|
}
|
|
return out
|
|
}
|