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) + } +}