LB buckets column + health cascade; VPP dump fix; maglevc strictness
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>
This commit is contained in:
@@ -88,9 +88,25 @@ func (ql *questionListener) OnChange(line []rune, pos int, key rune) (newLine []
|
||||
// Walk the confirmed prefix to the current node, then try to advance one
|
||||
// more step using the partial token (via prefix-match or slot fallback).
|
||||
// This mirrors birdc: "sh?" expands "sh" to "show" and shows show's subtree.
|
||||
node, _ := Walk(ql.root, prefix)
|
||||
node, _, remaining := Walk(ql.root, prefix)
|
||||
displayPrefix := strings.Join(prefix, " ")
|
||||
if partial != "" {
|
||||
var unknownMsg string
|
||||
if len(remaining) > 0 {
|
||||
// One of the confirmed prefix tokens was unknown. Show an
|
||||
// "unknown" banner, then list what's available at the deepest
|
||||
// node we *did* reach so the operator can see what they could
|
||||
// have typed instead. The partial at the cursor is irrelevant
|
||||
// once the left context is already broken.
|
||||
consumed := prefix[:len(prefix)-len(remaining)]
|
||||
bad := remaining[0]
|
||||
if len(consumed) == 0 {
|
||||
unknownMsg = fmt.Sprintf("unknown command: %s", bad)
|
||||
} else {
|
||||
unknownMsg = fmt.Sprintf("unknown subcommand %q after %q", bad, strings.Join(consumed, " "))
|
||||
}
|
||||
displayPrefix = strings.Join(consumed, " ")
|
||||
partial = ""
|
||||
} else if partial != "" {
|
||||
if next := matchFixedChild(node.Children, partial); next != nil {
|
||||
// Partial uniquely matched a fixed child — descend into it.
|
||||
node = next
|
||||
@@ -127,7 +143,19 @@ func (ql *questionListener) OnChange(line []rune, pos int, key rune) (newLine []
|
||||
}
|
||||
|
||||
// Emit output. Raw terminal mode requires \r\n.
|
||||
fmt.Fprintf(ql.rl.Stderr(), "\r\n")
|
||||
//
|
||||
// readline's wrapWriter wraps every Write in a clean-write-print
|
||||
// cycle: it erases the current input line, runs our closure, and
|
||||
// redraws the prompt+buffer afterwards. That means starting the
|
||||
// output with a bare "\r\n" leaves the original row blank, so the
|
||||
// operator loses sight of what they typed. Instead we echo the
|
||||
// full "maglev> show vpp lb ?" ourselves as the first write —
|
||||
// that lands on the just-cleaned row, birdc-style, and the
|
||||
// subsequent Fprintfs each redraw a fresh prompt below the help.
|
||||
fmt.Fprintf(ql.rl.Stderr(), "%s%s\r\n", ql.rl.Config.Prompt, string(line))
|
||||
if unknownMsg != "" {
|
||||
fmt.Fprintf(ql.rl.Stderr(), " %s\r\n", unknownMsg)
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
fmt.Fprintf(ql.rl.Stderr(), " <no completions>\r\n")
|
||||
} else {
|
||||
|
||||
@@ -66,20 +66,40 @@ func runShell(ctx context.Context, client grpcapi.MaglevClient) error {
|
||||
|
||||
// dispatch walks the tree and executes the matched command.
|
||||
func dispatch(ctx context.Context, root *Node, client grpcapi.MaglevClient, tokens []string) error {
|
||||
node, args := Walk(root, tokens)
|
||||
node, args, remaining := Walk(root, tokens)
|
||||
|
||||
if len(remaining) > 0 {
|
||||
// One or more tokens couldn't be matched. Report the first
|
||||
// offending token with the consumed prefix for context; don't
|
||||
// dump the full command tree prefixed with garbage, which is
|
||||
// what the previous code did and what prompted this fix.
|
||||
consumed := tokens[:len(tokens)-len(remaining)]
|
||||
return unknownCommandError(consumed, remaining[0])
|
||||
}
|
||||
|
||||
if node.Run == nil {
|
||||
showHelp(root, tokens)
|
||||
showHelpAt(node, strings.Join(tokens, " "))
|
||||
return nil
|
||||
}
|
||||
|
||||
return node.Run(ctx, client, args)
|
||||
}
|
||||
|
||||
// showHelp prints all reachable commands from the given token path, birdc-style.
|
||||
func showHelp(root *Node, tokens []string) {
|
||||
node, _ := Walk(root, tokens)
|
||||
prefix := strings.Join(tokens, " ")
|
||||
// unknownCommandError builds the error returned by dispatch when the
|
||||
// tree walk couldn't consume the full token list. The format differs
|
||||
// slightly depending on whether any tokens were consumed, so the
|
||||
// message always points at the first unknown token and its context.
|
||||
func unknownCommandError(consumed []string, bad string) error {
|
||||
if len(consumed) == 0 {
|
||||
return fmt.Errorf("unknown command: %s", bad)
|
||||
}
|
||||
return fmt.Errorf("unknown subcommand %q after %q", bad, strings.Join(consumed, " "))
|
||||
}
|
||||
|
||||
// showHelpAt prints the reachable leaves below node, each displayed
|
||||
// with the given prefix. Split from dispatch so the caller can decide
|
||||
// which node to anchor the help at without re-walking the tree.
|
||||
func showHelpAt(node *Node, prefix string) {
|
||||
lines := expandPaths(node, prefix, make(map[*Node]bool))
|
||||
|
||||
maxLen := 0
|
||||
|
||||
@@ -23,8 +23,12 @@ type Node struct {
|
||||
// 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 and the args collected from slot nodes.
|
||||
func Walk(root *Node, tokens []string) (*Node, []string) {
|
||||
// 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 {
|
||||
@@ -47,10 +51,11 @@ func Walk(root *Node, tokens []string) (*Node, []string) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Dead end — no match.
|
||||
// Dead end — no match. The caller gets the still-unconsumed tail
|
||||
// in the third return value.
|
||||
break
|
||||
}
|
||||
return node, args
|
||||
return node, args, tokens
|
||||
}
|
||||
|
||||
// matchFixedChild returns the child matching tok by exact then unique prefix,
|
||||
@@ -124,8 +129,14 @@ func expandPaths(node *Node, prefix string, visited map[*Node]bool) []helpLine {
|
||||
// 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.
|
||||
node, _ := Walk(root, tokens)
|
||||
// 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.
|
||||
|
||||
@@ -53,7 +53,7 @@ func TestExpandPathsRoot(t *testing.T) {
|
||||
|
||||
func TestExpandPathsShow(t *testing.T) {
|
||||
root := buildTree()
|
||||
showNode, _ := Walk(root, []string{"show"})
|
||||
showNode, _, _ := Walk(root, []string{"show"})
|
||||
lines := expandPaths(showNode, "show", make(map[*Node]bool))
|
||||
|
||||
for _, l := range lines {
|
||||
@@ -75,7 +75,7 @@ func TestExpandPathsShow(t *testing.T) {
|
||||
func TestExpandPathsNoCycles(t *testing.T) {
|
||||
root := buildTree()
|
||||
// watch events has a self-referencing slot; expandPaths must terminate.
|
||||
watchEvents, _ := Walk(root, []string{"watch", "events"})
|
||||
watchEvents, _, _ := Walk(root, []string{"watch", "events"})
|
||||
lines := expandPaths(watchEvents, "watch events", make(map[*Node]bool))
|
||||
|
||||
// Should produce exactly 2 lines: "watch events" and "watch events <opt>".
|
||||
@@ -87,7 +87,7 @@ func TestExpandPathsNoCycles(t *testing.T) {
|
||||
func TestExpandPathsSetBackendName(t *testing.T) {
|
||||
root := buildTree()
|
||||
// Walk to the name slot so displayPrefix carries the actual arg.
|
||||
node, _ := Walk(root, []string{"set", "backend", "mybackend"})
|
||||
node, _, _ := Walk(root, []string{"set", "backend", "mybackend"})
|
||||
lines := expandPaths(node, "set backend mybackend", make(map[*Node]bool))
|
||||
|
||||
want := []string{
|
||||
@@ -110,31 +110,37 @@ func TestPrefixMatchCollapsedNouns(t *testing.T) {
|
||||
root := buildTree()
|
||||
|
||||
// "sh ba" → show backends (list all) via prefix matching.
|
||||
node, args := Walk(root, []string{"sh", "ba"})
|
||||
node, args, rem := Walk(root, []string{"sh", "ba"})
|
||||
if node.Run == nil {
|
||||
t.Fatal("'sh ba' did not reach a Run node")
|
||||
}
|
||||
if len(args) != 0 {
|
||||
t.Errorf("'sh ba' should have 0 args, got %v", args)
|
||||
}
|
||||
if len(rem) != 0 {
|
||||
t.Errorf("'sh ba' should fully consume tokens, got remaining %v", rem)
|
||||
}
|
||||
|
||||
// "sh ba nginx0" → show backends <name> (get specific) via slot.
|
||||
node, args = Walk(root, []string{"sh", "ba", "nginx0"})
|
||||
node, args, rem = Walk(root, []string{"sh", "ba", "nginx0"})
|
||||
if node.Run == nil {
|
||||
t.Fatal("'sh ba nginx0' did not reach a Run node")
|
||||
}
|
||||
if len(args) != 1 || args[0] != "nginx0" {
|
||||
t.Errorf("'sh ba nginx0' args: got %v, want [nginx0]", args)
|
||||
}
|
||||
if len(rem) != 0 {
|
||||
t.Errorf("'sh ba nginx0' should fully consume tokens, got remaining %v", rem)
|
||||
}
|
||||
|
||||
// "sh fr" → show frontends (list all).
|
||||
node, _ = Walk(root, []string{"sh", "fr"})
|
||||
node, _, _ = Walk(root, []string{"sh", "fr"})
|
||||
if node.Run == nil {
|
||||
t.Fatal("'sh fr' did not reach a Run node")
|
||||
}
|
||||
|
||||
// "sh he icmp" → show healthchecks icmp (get specific).
|
||||
node, args = Walk(root, []string{"sh", "he", "icmp"})
|
||||
node, args, _ = Walk(root, []string{"sh", "he", "icmp"})
|
||||
if node.Run == nil {
|
||||
t.Fatal("'sh he icmp' did not reach a Run node")
|
||||
}
|
||||
@@ -143,11 +149,37 @@ func TestPrefixMatchCollapsedNouns(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWalkUnknownTokens(t *testing.T) {
|
||||
root := buildTree()
|
||||
|
||||
// A bare unknown word leaves every token unconsumed and anchors
|
||||
// the returned node at the root — callers must treat this as
|
||||
// "unknown command" rather than silently showing the whole tree.
|
||||
node, _, rem := Walk(root, []string{"foo"})
|
||||
if node != root {
|
||||
t.Errorf("'foo' should leave walk at root, got %q", node.Word)
|
||||
}
|
||||
if len(rem) != 1 || rem[0] != "foo" {
|
||||
t.Errorf("'foo' remaining: got %v, want [foo]", rem)
|
||||
}
|
||||
|
||||
// Partial consumption: "show" matches but "bogus" doesn't. The
|
||||
// returned remaining is the first unmatched token onwards so the
|
||||
// caller can point at exactly what was wrong.
|
||||
node, _, rem = Walk(root, []string{"show", "bogus", "tail"})
|
||||
if node.Word != "show" {
|
||||
t.Errorf("'show bogus tail' should stop at show, got %q", node.Word)
|
||||
}
|
||||
if len(rem) != 2 || rem[0] != "bogus" || rem[1] != "tail" {
|
||||
t.Errorf("'show bogus tail' remaining: got %v, want [bogus tail]", rem)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandPathsWeightSlotWalk(t *testing.T) {
|
||||
// Verify the weight command is fully walkable (fixes bug: setWeightValue
|
||||
// and setFrontendPoolName were non-slot nodes that couldn't capture tokens).
|
||||
root := buildTree()
|
||||
node, args := Walk(root, []string{"set", "frontend", "web", "pool", "primary", "backend", "be0", "weight", "42"})
|
||||
node, args, _ := Walk(root, []string{"set", "frontend", "web", "pool", "primary", "backend", "be0", "weight", "42"})
|
||||
if node.Run == nil {
|
||||
t.Fatal("Walk did not reach a Run node for full weight command")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user