From e44f8b7aba87fcd7b3b7c2c0c1aa931da7bcb36a Mon Sep 17 00:00:00 2001 From: Matt Jenkinson <75292329+mattdjenkinson@users.noreply.github.com> Date: Tue, 4 Aug 2026 11:20:53 +0100 Subject: [PATCH] feat: show PolicyStatus ancestor conditions in TUI TrafficProtectionPolicy (and other Gateway API policies) keep conditions under status.ancestors, so the conditions pane reported nothing for TPP Programmed. Flatten ancestor conditions into the existing table, prefixing each row with the ancestor ref so Accepted and Programmed are visible. Related to datum-cloud/network-services-operator#266 --- internal/console/components/detailview.go | 77 +++++++++++++++++-- .../console/components/detailview_test.go | 47 +++++++++++ 2 files changed, 117 insertions(+), 7 deletions(-) diff --git a/internal/console/components/detailview.go b/internal/console/components/detailview.go index ca88314..59ab965 100644 --- a/internal/console/components/detailview.go +++ b/internal/console/components/detailview.go @@ -257,6 +257,7 @@ type conditionRow struct { Reason string Message string LastTransitionTime string // formatted "2006-01-02 15:04:05"; "" if missing/unparseable + Ancestor string // optional Gateway API PolicyStatus ancestor label } // parseConditionRow extracts a conditionRow from an interface{} condition entry. @@ -293,6 +294,8 @@ func parseConditionRow(raw interface{}) conditionRow { } // RenderConditionsTable parses .status.conditions from raw and renders a width-banded table. +// When top-level conditions are absent, it falls back to Gateway API PolicyStatus +// .status.ancestors[].conditions (e.g. TrafficProtectionPolicy Programmed). // Width bands: [0,40) unusable, [40,60) narrow (T/S/R), [60,80) standard (T/S/R/LTT), // [80,∞) wide (T/S/R/M/LTT). Non-Ready rows (status != "True") rendered in styles.Warning. // Returns a muted placeholder when conditions are absent, empty, or unparseable (AC#11/12/13). @@ -302,20 +305,80 @@ func RenderConditionsTable(raw *unstructured.Unstructured, width int) string { / return mutedStyle.Render("Terminal too narrow — widen to 40+ columns") } - conditions, found, err := unstructuredNestedSlice(raw.Object, "status", "conditions") - if err != nil { // AC#13 — malformed .status structure + rows := collectConditionRows(raw.Object) + if rows == nil { return mutedStyle.Render("Conditions unavailable for this resource type.") } - if !found || len(conditions) == 0 { // AC#11 / AC#12 + if len(rows) == 0 { // AC#11 / AC#12 return mutedStyle.Render("No conditions reported for this resource.") } - rows := make([]conditionRow, 0, len(conditions)) - for _, c := range conditions { - rows = append(rows, parseConditionRow(c)) + return renderConditionsBody(rows, width) +} + +// collectConditionRows prefers .status.conditions, then flattens PolicyStatus +// ancestors. Returns nil only when the status structure is malformed. +func collectConditionRows(obj map[string]interface{}) []conditionRow { + conditions, found, err := unstructuredNestedSlice(obj, "status", "conditions") + if err != nil { + return nil + } + if found && len(conditions) > 0 { + rows := make([]conditionRow, 0, len(conditions)) + for _, c := range conditions { + rows = append(rows, parseConditionRow(c)) + } + return rows } - return renderConditionsBody(rows, width) + ancestors, found, err := unstructuredNestedSlice(obj, "status", "ancestors") + if err != nil { + return nil + } + if !found || len(ancestors) == 0 { + return []conditionRow{} + } + + rows := make([]conditionRow, 0) + for _, rawAncestor := range ancestors { + ancestor, ok := rawAncestor.(map[string]interface{}) + if !ok { + continue + } + label := ancestorLabel(ancestor) + conds, _, _ := unstructured.NestedSlice(ancestor, "conditions") + for _, c := range conds { + row := parseConditionRow(c) + row.Ancestor = label + if row.Type != "" { + if label != "" { + row.Type = label + "/" + row.Type + } + rows = append(rows, row) + } + } + } + return rows +} + +func ancestorLabel(ancestor map[string]interface{}) string { + ref, ok := ancestor["ancestorRef"].(map[string]interface{}) + if !ok { + return "" + } + kind, _ := ref["kind"].(string) + name, _ := ref["name"].(string) + namespace, _ := ref["namespace"].(string) + switch { + case kind != "" && namespace != "" && name != "": + return kind + "/" + namespace + "/" + name + case kind != "" && name != "": + return kind + "/" + name + case name != "": + return name + default: + return "" + } } // unstructuredNestedSlice is a thin wrapper around k8s unstructured helpers to diff --git a/internal/console/components/detailview_test.go b/internal/console/components/detailview_test.go index 93e0219..689d2b4 100644 --- a/internal/console/components/detailview_test.go +++ b/internal/console/components/detailview_test.go @@ -11,6 +11,7 @@ import ( "charm.land/bubbles/v2/spinner" k8serrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" "go.datum.net/datumctl/internal/console/data" ) @@ -1278,3 +1279,49 @@ func TestFB123_AC7_AntiRegression_DescribeUnavailable_RHintStillPresent(t *testi } // ==================== End FB-123 (component layer) ==================== + +func TestRenderConditionsTable_AncestorsFallback(t *testing.T) { + t.Parallel() + raw := &unstructured.Unstructured{Object: map[string]interface{}{ + "apiVersion": "networking.datumapis.com/v1alpha", + "kind": "TrafficProtectionPolicy", + "metadata": map[string]interface{}{"name": "waf"}, + "status": map[string]interface{}{ + "ancestors": []interface{}{ + map[string]interface{}{ + "ancestorRef": map[string]interface{}{ + "kind": "Gateway", + "name": "edge-gw", + "namespace": "proj", + }, + "conditions": []interface{}{ + map[string]interface{}{ + "type": "Accepted", + "status": "True", + "reason": "Accepted", + "message": "Policy has been accepted.", + "lastTransitionTime": "2026-08-04T10:00:00Z", + }, + map[string]interface{}{ + "type": "Programmed", + "status": "True", + "reason": "Programmed", + "message": "3/3 edges programmed generation 2", + "lastTransitionTime": "2026-08-04T10:00:05Z", + }, + }, + }, + }, + }, + }} + + got := stripANSI(RenderConditionsTable(raw, 120)) + for _, want := range []string{"Programmed", "Accepted", "Gateway/proj/edge-gw", "3/3 edges"} { + if !strings.Contains(got, want) { + t.Errorf("ancestors conditions table missing %q:\n%s", want, got) + } + } + if strings.Contains(got, "No conditions reported") { + t.Errorf("unexpected empty placeholder for PolicyStatus ancestors:\n%s", got) + } +}