Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/reference/cluster.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down
17 changes: 11 additions & 6 deletions internal/commands/cluster/upgradecheck.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) },
Expand All @@ -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
}
Expand Down
31 changes: 28 additions & 3 deletions internal/commands/clusterview/insights.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package clusterview
import (
"fmt"
"os"
"sort"
"strings"

"github.com/fatih/color"
Expand Down Expand Up @@ -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},
Expand All @@ -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()

Expand All @@ -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 != "" {
Expand All @@ -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))
}
Expand All @@ -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)
}
Expand Down
131 changes: 131 additions & 0 deletions internal/commands/clusterview/insights_render.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package clusterview

import (
"fmt"
"sort"
"strings"

"github.com/dantech2000/refresh/internal/health"
Expand Down Expand Up @@ -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},
Expand All @@ -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),
Expand All @@ -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 <id|name>"))
}

if cp := report.ControlPlane; cp != nil {
Expand All @@ -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 {
Expand Down
79 changes: 79 additions & 0 deletions internal/commands/clusterview/insights_render_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Loading
Loading