diff --git a/docs/reference/cluster.md b/docs/reference/cluster.md index f49042c..53f04a9 100644 --- a/docs/reference/cluster.md +++ b/docs/reference/cluster.md @@ -120,7 +120,7 @@ Examples: | `--category string` | — | `UPGRADE_READINESS` | Insight category (UPGRADE_READINESS, MISCONFIGURATION) | | `--status string` | — | — | Filter by insight status (PASSING, WARNING, ERROR, UNKNOWN) | | `--show-passing` | — | — | Include PASSING insights (hidden by default) | -| `--id string` | — | — | Show the detail view (recommendation + resources) for a single insight ID | +| `--id string` | — | — | Show the detail view for one insight — accepts its ID, a short ID prefix (as shown in the table), or a name substring | | `--format, -o string` | — | `table` | Output format (table, json, yaml, plain) | | `--help, -h` | — | — | show help | diff --git a/internal/commands/cluster/upgradecheck.go b/internal/commands/cluster/upgradecheck.go index d31989a..c4d6851 100644 --- a/internal/commands/cluster/upgradecheck.go +++ b/internal/commands/cluster/upgradecheck.go @@ -38,7 +38,7 @@ Examples: &cli.StringFlag{Name: "category", Usage: "Insight category (UPGRADE_READINESS, MISCONFIGURATION)", Value: "UPGRADE_READINESS"}, &cli.StringSliceFlag{Name: "status", Usage: "Filter by insight status (PASSING, WARNING, ERROR, UNKNOWN)"}, &cli.BoolFlag{Name: "show-passing", Usage: "Include PASSING insights (hidden by default)"}, - &cli.StringFlag{Name: "id", Usage: "Show the detail view (recommendation + resources) for a single insight ID"}, + &cli.StringFlag{Name: "id", Usage: "Show the detail view for one insight — accepts its ID, a short ID prefix (as shown in the table), or a name substring"}, &cli.StringFlag{Name: "format", Aliases: []string{"o"}, Usage: "Output format (table, json, yaml, plain)", Value: "table"}, }, Action: func(ctx context.Context, cmd *cli.Command) error { return runUpgradeCheck(ctx, cmd) }, @@ -62,13 +62,18 @@ func runUpgradeCheck(ctx context.Context, cmd *cli.Command) error { service := factory.NewClusterService(awsCfg, false, nil) - // Detail view for a single insight. - if id := cmd.String("id"); id != "" { + // Detail view for a single insight. The --id value may be a full insight ID, + // a short ID prefix (as shown in the insights table), or a case-insensitive + // name substring — so the user never has to copy a raw UUID. + if q := cmd.String("id"); q != "" { var detail *clustersvc.InsightDetail if werr := runner.WithSpinner("cluster", "Insight detail loaded!", func() error { - var derr error - detail, derr = service.DescribeInsight(ctx, clusterName, id) - return derr + id, rerr := service.ResolveInsightID(ctx, clusterName, q) + if rerr != nil { + return rerr + } + detail, rerr = service.DescribeInsight(ctx, clusterName, id) + return rerr }); werr != nil { return werr } diff --git a/internal/commands/clusterview/insights.go b/internal/commands/clusterview/insights.go index 4896b67..6463dce 100644 --- a/internal/commands/clusterview/insights.go +++ b/internal/commands/clusterview/insights.go @@ -3,6 +3,7 @@ package clusterview import ( "fmt" "os" + "sort" "strings" "github.com/fatih/color" @@ -61,6 +62,7 @@ func outputInsights(insights []clustersvc.InsightSummary) { } tbl := ui.NewPTable([]ui.Column{ + {Title: "ID", Min: 8, Align: ui.AlignLeft}, {Title: "NAME", Min: 24, Max: 48, Align: ui.AlignLeft}, {Title: "CATEGORY", Min: 16, Align: ui.AlignLeft}, {Title: "STATUS", Min: 8, Align: ui.AlignLeft}, @@ -84,7 +86,7 @@ func outputInsights(insights []clustersvc.InsightSummary) { if in.LastRefreshTime != nil { refresh = in.LastRefreshTime.Format(insightTimeLayout) } - tbl.AddRow(in.Name, in.Category, formatInsightStatus(in.Status), valueOrDash(in.KubernetesVersion), refresh) + tbl.AddRow(shortID(in.ID), in.Name, in.Category, formatInsightStatus(in.Status), valueOrDash(in.KubernetesVersion), refresh) } tbl.Render() @@ -110,9 +112,18 @@ func outputSkew(skew clustersvc.SkewReport) { } } -// OutputInsightDetail renders a single insight's recommendation and affected -// resources (the DescribeInsight detail view). +// OutputInsightDetail renders a single insight (the DescribeInsight detail +// view). The human path uses the render design system (header + sections); +// `-o plain` keeps an uncolored label/value layout for grep. func OutputInsightDetail(detail *clustersvc.InsightDetail) error { + if !ui.PlainOutput() { + th := render.Default(os.Stdout) + for _, line := range insightDetailLines(th, detail) { + fmt.Println(line) + } + return nil + } + ui.Outf("Insight: %s\n", detail.Name) fmt.Printf(" Status: %s\n", formatInsightStatus(detail.Status)) if detail.StatusReason != "" { @@ -122,6 +133,9 @@ func OutputInsightDetail(detail *clustersvc.InsightDetail) error { if detail.KubernetesVersion != "" { fmt.Printf(" K8s: %s\n", detail.KubernetesVersion) } + if detail.ID != "" { + fmt.Printf(" ID: %s\n", detail.ID) + } if detail.Description != "" { fmt.Printf("\n Description:\n %s\n", oneLine(detail.Description)) } @@ -134,6 +148,17 @@ func OutputInsightDetail(detail *clustersvc.InsightDetail) error { fmt.Printf(" - %s\n", r) } } + if len(detail.AdditionalInfo) > 0 { + fmt.Printf("\n More information:\n") + keys := make([]string, 0, len(detail.AdditionalInfo)) + for k := range detail.AdditionalInfo { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + fmt.Printf(" %s: %s\n", k, detail.AdditionalInfo[k]) + } + } for _, line := range deprecationLines(detail.Deprecations) { fmt.Println(line) } diff --git a/internal/commands/clusterview/insights_render.go b/internal/commands/clusterview/insights_render.go index a8486fd..e82e0a6 100644 --- a/internal/commands/clusterview/insights_render.go +++ b/internal/commands/clusterview/insights_render.go @@ -2,6 +2,7 @@ package clusterview import ( "fmt" + "sort" "strings" "github.com/dantech2000/refresh/internal/health" @@ -92,6 +93,7 @@ func upgradeCheckLines(th *render.Theme, report *clustersvc.UpgradeReport) []str out = append(out, " "+th.Token(render.Healthy, "no upgrade insights to address")) } else { tbl := th.NewTable( + ui.Column{Title: "ID", Min: 8}, ui.Column{Title: "NAME", Min: 20, Max: 48}, ui.Column{Title: "CATEGORY", Min: 14}, ui.Column{Title: "STATUS", Min: 10}, @@ -113,6 +115,7 @@ func upgradeCheckLines(th *render.Theme, report *clustersvc.UpgradeReport) []str refresh = in.LastRefreshTime.Format(insightTimeLayout) } tbl.Row( + th.Paint(pal.Dim, shortID(in.ID)), th.Paint(pal.White, in.Name), th.Paint(pal.Text, in.Category), insightToken(th, in.Status), @@ -124,6 +127,9 @@ func upgradeCheckLines(th *render.Theme, report *clustersvc.UpgradeReport) []str out = append(out, " "+l) } out = append(out, " "+insightCountChips(th, errc, warnc, passc)) + // Tell the user how to drill in — the detail view accepts the short ID + // above or a name substring, so they never need to copy a raw UUID. + out = append(out, " "+th.Paint(pal.Dim, "drill into one: cluster upgrade-check -c "+report.Cluster+" --id ")) } if cp := report.ControlPlane; cp != nil { @@ -149,6 +155,131 @@ func upgradeCheckLines(th *render.Theme, report *clustersvc.UpgradeReport) []str return out } +// shortID trims an insight UUID to a copy-pasteable prefix for the table; the +// detail view accepts this prefix (or a name) so the full UUID never has to be +// typed. +func shortID(id string) string { + if len(id) > 8 { + return id[:8] + } + if id == "" { + return "-" + } + return id +} + +// wrapText collapses whitespace in a (possibly Markdown) blob and word-wraps it +// to width, so long descriptions/recommendations read as a clean paragraph +// instead of one runaway line. Deterministic — golden-testable. +func wrapText(s string, width int) []string { + words := strings.Fields(s) + if len(words) == 0 { + return nil + } + lines := make([]string, 0) + cur := words[0] + for _, w := range words[1:] { + if len(cur)+1+len(w) > width { + lines = append(lines, cur) + cur = w + continue + } + cur += " " + w + } + return append(lines, cur) +} + +// insightDetailLines builds the human DescribeInsight detail view in the design +// system (pure, golden-testable): header + status, overview, description, +// recommendation, affected resources, doc links (additionalInfo), and — for +// deprecated-API insights — the per-API caller breakdown. +func insightDetailLines(th *render.Theme, d *clustersvc.InsightDetail) []string { + pal := th.Pal + const wrap = 88 + + header := th.Bold(pal.White, valueOrDash(d.Name)) + " " + insightToken(th, d.Status) + out := []string{header} + if d.StatusReason != "" { + out = append(out, " "+th.Paint(pal.Dim, d.StatusReason)) + } + + kv := [][2]string{{"category", th.Paint(pal.Text, valueOrDash(d.Category))}} + if d.KubernetesVersion != "" { + kv = append(kv, [2]string{"targets", th.Paint(pal.White, d.KubernetesVersion)}) + } + if d.ID != "" { + kv = append(kv, [2]string{"id", th.Paint(pal.Dim, d.ID)}) + } + if d.LastRefreshTime != nil { + kv = append(kv, [2]string{"refreshed", th.Paint(pal.Dim, d.LastRefreshTime.Format(insightTimeLayout))}) + } + out = append(out, "", th.Section("OVERVIEW")) + for _, l := range th.KV(kv) { + out = append(out, " "+l) + } + + if d.Description != "" { + out = append(out, "", th.Section("DESCRIPTION")) + for _, l := range wrapText(d.Description, wrap) { + out = append(out, " "+th.Paint(pal.Text, l)) + } + } + if d.Recommendation != "" { + out = append(out, "", th.Section("RECOMMENDATION")) + for _, l := range wrapText(d.Recommendation, wrap) { + out = append(out, " "+th.Paint(pal.White, l)) + } + } + if len(d.Resources) > 0 { + out = append(out, "", th.Section("AFFECTED RESOURCES")+th.Paint(pal.Dim, fmt.Sprintf(" %d", len(d.Resources)))) + for _, r := range d.Resources { + out = append(out, " "+th.Paint(pal.Text, "- "+r)) + } + } + if len(d.AdditionalInfo) > 0 { + out = append(out, "", th.Section("MORE INFORMATION")) + keys := make([]string, 0, len(d.AdditionalInfo)) + for k := range d.AdditionalInfo { + keys = append(keys, k) + } + sort.Strings(keys) // deterministic order (map iteration is random) + for _, k := range keys { + out = append(out, " "+th.Paint(pal.White, k), " "+th.Paint(pal.Sky, d.AdditionalInfo[k])) + } + } + return append(out, insightDeprecationLines(th, d.Deprecations)...) +} + +// insightDeprecationLines renders the deprecated-API breakdown in the design +// system: each deprecated API → its replacement (and removal version), then the +// clients still calling it (most-active first), plus the 30-day audit caveat. +func insightDeprecationLines(th *render.Theme, deps []clustersvc.DeprecationDetail) []string { + if len(deps) == 0 { + return nil + } + pal := th.Pal + out := []string{"", th.Section("DEPRECATED APIs") + th.Paint(pal.Dim, fmt.Sprintf(" %d", len(deps)))} + for _, d := range deps { + head := valueOrDash(d.Usage) + if d.ReplacedWith != "" { + head += " → " + d.ReplacedWith + } + if d.StopServingVersion != "" { + head += fmt.Sprintf(" (removed in %s)", d.StopServingVersion) + } + out = append(out, " "+th.Token(render.Fail, head)) + for _, c := range d.ClientStats { + last := "-" + if c.LastRequestTime != nil { + last = c.LastRequestTime.Format(insightTimeLayout) + } + out = append(out, " "+th.Paint(pal.White, valueOrDash(c.UserAgent))+ + th.Paint(pal.Dim, fmt.Sprintf(" · %d req/30d · last seen %s", c.NumberOfRequestsLast30Days, last))) + } + } + return append(out, " "+th.Paint(pal.Dim, "note: EKS reads audit logs on a 30-day window — a check stays ERROR until the last call ages out.")) +} + func insightCountChips(th *render.Theme, errc, warnc, passc int) string { var parts []string if errc > 0 { diff --git a/internal/commands/clusterview/insights_render_test.go b/internal/commands/clusterview/insights_render_test.go index 47df79f..9dc5471 100644 --- a/internal/commands/clusterview/insights_render_test.go +++ b/internal/commands/clusterview/insights_render_test.go @@ -10,6 +10,85 @@ import ( clustersvc "github.com/dantech2000/refresh/internal/services/cluster" ) +func TestWrapText(t *testing.T) { + if got := wrapText("a b c d e", 3); strings.Join(got, "|") != "a b|c d|e" { + t.Errorf("wrapText width=3 = %v, want [a b|c d|e]", got) + } + // Collapses arbitrary whitespace/newlines into a single wrapped paragraph. + if got := wrapText(" alpha\n\n beta gamma ", 80); strings.Join(got, "|") != "alpha beta gamma" { + t.Errorf("wrapText collapse = %v", got) + } + if got := wrapText(" ", 80); got != nil { + t.Errorf("blank input should yield nil, got %v", got) + } +} + +func TestInsightDetailLines(t *testing.T) { + th := render.New(render.ColorNone, true) + + // AL2-style PASSING insight: rich recommendation + additionalInfo, no + // resources, no deprecations (mirrors the real DescribeInsight output). + d := &clustersvc.InsightDetail{ + InsightSummary: clustersvc.InsightSummary{ + ID: "bc8b2f86-6650-4ee3-a7b9-70dab041a350", Name: "Amazon Linux 2 compatibility", + Category: "UPGRADE_READINESS", Status: clustersvc.InsightStatusPassing, + StatusReason: "No Amazon Linux 2 nodes detected.", KubernetesVersion: "1.35", + }, + Recommendation: "Migrate all EKS nodes using Amazon Linux 2 AMIs to Bottlerocket or Amazon Linux 2023 AMIs before the deadline.", + AdditionalInfo: map[string]string{ + "Migrating to Amazon Linux 2023": "https://docs.aws.amazon.com/eks/latest/userguide/al2023.html", + "Create nodes with Bottlerocket": "https://docs.aws.amazon.com/eks/latest/userguide/eks-optimized-ami-bottlerocket.html", + }, + } + joined := strings.Join(insightDetailLines(th, d), "\n") + if strings.Contains(joined, "\x1b") { + t.Fatalf("ColorNone detail contains ANSI:\n%s", joined) + } + for _, want := range []string{ + "Amazon Linux 2 compatibility ● PASSING", + "No Amazon Linux 2 nodes detected.", + "▸ OVERVIEW", "category", "targets", "1.35", + "▸ RECOMMENDATION", "Migrate all EKS nodes", + "▸ MORE INFORMATION", + "Migrating to Amazon Linux 2023", + "https://docs.aws.amazon.com/eks/latest/userguide/al2023.html", + } { + if !strings.Contains(joined, want) { + t.Errorf("detail view missing %q in:\n%s", want, joined) + } + } + // additionalInfo is sorted (deterministic): "Create…" sorts before "Migrating…". + if strings.Index(joined, "Create nodes with Bottlerocket") > strings.Index(joined, "Migrating to Amazon Linux 2023") { + t.Error("additionalInfo links should be sorted alphabetically") + } + if strings.Contains(joined, "DEPRECATED APIs") { + t.Errorf("no deprecations → no DEPRECATED APIs section:\n%s", joined) + } +} + +func TestInsightDetailLines_Deprecations(t *testing.T) { + th := render.New(render.ColorNone, true) + last := time.Date(2026, 6, 14, 9, 30, 0, 0, time.UTC) + d := &clustersvc.InsightDetail{ + InsightSummary: clustersvc.InsightSummary{Name: "Deprecated APIs removed in 1.33", Status: clustersvc.InsightStatusError}, + Deprecations: []clustersvc.DeprecationDetail{{ + Usage: "policy/v1beta1 PodDisruptionBudget", ReplacedWith: "policy/v1 PodDisruptionBudget", StopServingVersion: "1.25", + ClientStats: []clustersvc.ClientStat{{UserAgent: "newrelic-kube-state-metric/v2", LastRequestTime: &last, NumberOfRequestsLast30Days: 412}}, + }}, + } + joined := strings.Join(insightDetailLines(th, d), "\n") + for _, want := range []string{ + "▸ DEPRECATED APIs", + "policy/v1beta1 PodDisruptionBudget → policy/v1 PodDisruptionBudget (removed in 1.25)", + "newrelic-kube-state-metric/v2", "412 req/30d", "last seen 2026-06-14 09:30", + "30-day window", + } { + if !strings.Contains(joined, want) { + t.Errorf("deprecation detail missing %q in:\n%s", want, joined) + } + } +} + func TestUpgradeCheckLines_ControlPlaneGate(t *testing.T) { th := render.New(render.ColorNone, true) diff --git a/internal/services/cluster/insights.go b/internal/services/cluster/insights.go index 51f91df..d5d99bc 100644 --- a/internal/services/cluster/insights.go +++ b/internal/services/cluster/insights.go @@ -2,6 +2,7 @@ package cluster import ( "context" + "errors" "fmt" "sort" "strconv" @@ -173,6 +174,46 @@ func (s *ServiceImpl) ListInsights(ctx context.Context, clusterName string, opts return result, nil } +// ResolveInsightID turns a user-supplied reference — a full insight ID, a short +// ID prefix (as shown in the upgrade-check table), or a case-insensitive name +// substring — into a canonical insight ID. It lists all insights (including +// PASSING) so anything visible in the table can be drilled into. An exact ID +// wins outright; a single prefix/name match resolves; an ambiguous query errors +// with the candidates so the user can narrow it down. +func (s *ServiceImpl) ResolveInsightID(ctx context.Context, clusterName, query string) (string, error) { + all, err := s.ListInsights(ctx, clusterName, UpgradeCheckOptions{ShowPassing: true}) + if err != nil { + return "", err + } + q := strings.ToLower(strings.TrimSpace(query)) + var matches []InsightSummary + for _, in := range all { + if strings.EqualFold(in.ID, query) { + return in.ID, nil + } + if (len(q) >= 4 && strings.HasPrefix(strings.ToLower(in.ID), q)) || strings.Contains(strings.ToLower(in.Name), q) { + matches = append(matches, in) + } + } + switch len(matches) { + case 1: + return matches[0].ID, nil + case 0: + return "", fmt.Errorf("no insight matches %q; run with --show-passing to list them", query) + default: + var b strings.Builder + fmt.Fprintf(&b, "%q matches %d insights — narrow it down:", query, len(matches)) + for _, m := range matches { + sid := m.ID + if len(sid) > 8 { + sid = sid[:8] + } + fmt.Fprintf(&b, "\n %-9s %s", sid, m.Name) + } + return "", errors.New(b.String()) + } +} + // DescribeInsight returns the detail view (recommendation, affected resources) // for a single insight. func (s *ServiceImpl) DescribeInsight(ctx context.Context, clusterName, id string) (*InsightDetail, error) { diff --git a/internal/services/cluster/insights_test.go b/internal/services/cluster/insights_test.go index db48ef6..f7b3b0f 100644 --- a/internal/services/cluster/insights_test.go +++ b/internal/services/cluster/insights_test.go @@ -2,6 +2,7 @@ package cluster import ( "context" + "strings" "testing" "time" @@ -143,6 +144,48 @@ func TestDescribeInsight_Deprecations(t *testing.T) { } } +func TestResolveInsightID(t *testing.T) { + mock := &mocks.EKSAPI{ + ListInsightsFn: func(_ context.Context, _ *eks.ListInsightsInput, _ ...func(*eks.Options)) (*eks.ListInsightsOutput, error) { + return &eks.ListInsightsOutput{Insights: []ekstypes.InsightSummary{ + {Id: aws.String("bc8b2f86-aaaa"), Name: aws.String("Amazon Linux 2 compatibility"), Category: ekstypes.CategoryUpgradeReadiness, InsightStatus: &ekstypes.InsightStatus{Status: ekstypes.InsightStatusValuePassing}}, + {Id: aws.String("d52457c8-bbbb"), Name: aws.String("Kubelet version skew"), Category: ekstypes.CategoryUpgradeReadiness, InsightStatus: &ekstypes.InsightStatus{Status: ekstypes.InsightStatusValuePassing}}, + {Id: aws.String("0e6b6f8f-cccc"), Name: aws.String("kube-proxy version skew"), Category: ekstypes.CategoryUpgradeReadiness, InsightStatus: &ekstypes.InsightStatus{Status: ekstypes.InsightStatusValuePassing}}, + {Id: aws.String("dep12345-dddd"), Name: aws.String("Deprecated APIs removed in 1.33"), Category: ekstypes.CategoryUpgradeReadiness, InsightStatus: &ekstypes.InsightStatus{Status: ekstypes.InsightStatusValueError}}, + }}, nil + }, + } + svc := &ServiceImpl{eksClient: mock} + ctx := context.Background() + + cases := []struct{ query, want string }{ + {"bc8b2f86-aaaa", "bc8b2f86-aaaa"}, // exact ID + {"d52457c8", "d52457c8-bbbb"}, // ID prefix + {"kubelet", "d52457c8-bbbb"}, // name substring (case-insensitive) + {"deprecated", "dep12345-dddd"}, // name substring + {"AMAZON LINUX", "bc8b2f86-aaaa"}, // case-insensitive name + } + for _, c := range cases { + got, err := svc.ResolveInsightID(ctx, "prod", c.query) + if err != nil { + t.Errorf("ResolveInsightID(%q): unexpected error: %v", c.query, err) + continue + } + if got != c.want { + t.Errorf("ResolveInsightID(%q) = %q, want %q", c.query, got, c.want) + } + } + + // Ambiguous: "skew" matches both version-skew insights → error naming candidates. + if _, err := svc.ResolveInsightID(ctx, "prod", "skew"); err == nil || !strings.Contains(err.Error(), "matches 2 insights") { + t.Errorf("ambiguous query should error with candidates, got %v", err) + } + // No match. + if _, err := svc.ResolveInsightID(ctx, "prod", "nonexistent-xyz"); err == nil || !strings.Contains(err.Error(), "no insight matches") { + t.Errorf("no-match query should error, got %v", err) + } +} + func TestUpgradeCheck_Skew(t *testing.T) { mock := &mocks.EKSAPI{ DescribeClusterFn: func(_ context.Context, _ *eks.DescribeClusterInput, _ ...func(*eks.Options)) (*eks.DescribeClusterOutput, error) {