Skip to content

Stop status badges reporting health nothing verified - #1659

Merged
nadaverell merged 1 commit into
mainfrom
fix/status-badges-claiming-unverified-health
Sep 7, 2026
Merged

nadaverell merged 1 commit into
mainfrom
fix/status-badges-claiming-unverified-health

Conversation

@nadaverell

@nadaverell nadaverell commented Sep 7, 2026 •

Copy link
Copy Markdown
Contributor

Six status cells painted a row green — or red — on evidence the object does not carry. This continues #1645 and #1649; a census of the remaining status accessors found the class had survived in five more places, and one of them was reading a field that does not exist.

What changed

Prometheus configuration resources (ServiceMonitor, PodMonitor, PrometheusRule). These read status.conditions, a path their schema does not define. The operator reports acceptance per selecting workload under status.bindings[].conditions, the only condition type is Accepted, and it writes any of it only when the StatusForConfigurationResources feature gate is enabled. The read never matched, so every row fell through to a green "Active" — including a configuration a Prometheus had explicitly rejected.

They now read the real path and report what it says. A resource is Accepted only when every workload that reported has accepted it: one rejection among several shows partial acceptance, a workload still reporting Unknown keeps the badge off green, an outright rejection surfaces its reason, and nothing reported reads Not assessed. A condition whose observedGeneration predates the object's is skipped rather than read as current.

Worth being precise about the ceiling: Accepted means the operator accepted and wrote the configuration. It does not mean a target exists or that scraping succeeds, and this badge does not claim it does.

Kyverno policies counted their own declared rules as readiness. Kyverno writes a Ready condition; declaring rules is not the controller accepting them. The fallback now reports Not assessed, which also covers Ready=Unknown and a conditions list with no Ready at all.

Istio ServiceEntry rendered spec.location in green. MESH_EXTERNAL / MESH_INTERNAL describe where a host sits relative to the mesh, not whether it resolves or answers — the text stays, the tone becomes neutral.

ServiceEntry and PeerAuthentication lose their Status column entirely, because in both cases it could only ever restate the column beside it.

getServiceEntryStatus and getServiceEntryLocation read spec.location with the same default and map it to the same two words. Their only divergence is No Hosts, and that state cannot exist: Istio's CRD declares hosts both required and minItems: 1 in all three served versions, so the API server rejects a ServiceEntry without them. Status was therefore identical to Location on every row.

PeerAuthentication is the same shape with no escape clause at all — both columns read spec.mtls.mode || 'UNSET' and encode the same four severities, differing only in wording (Strict mTLS beside STRICT).

Location and mTLS Mode are the honest primary representations and are kept. Both accessors stay: the ./components/resources/* export makes them importable, and the detail pages still use them.

Istio VirtualService called any object with hosts and routes "Active", and called every hostless one broken. Istio requires a delegate VirtualService to declare no hosts, and nothing on the object says whether a parent delegates to it, so red was a false alarm on a documented pattern. Hostless is now unknown; a declaration with routes is Defined. A VirtualService with no routes stays a real defect either way — a delegate omits hosts, but it exists to supply routes.

CNPG ObjectStore reported "Recoverable" from the presence of a server entry. Every timestamp on a RecoveryWindow is optional, so a server can be listed while holding nothing restorable — { pg: {} } was green. It now requires an actual recovery point.

Reviewer note

Badges take their colour from the returned color, not from level. Each change moves both; changing level alone would have left every row green.

Vocabulary follows the two prior PRs rather than inventing any: Not assessed / unknown where nothing confirmed anything (getDestinationRuleStatus), and neutral for a classification that is not a health claim (getIstioGatewayStatus's Defined).

Deliberately not in scope

  • pkg/topology/builder.go hardcodes StatusHealthy for ServiceMonitor and PodMonitor nodes and treats any routed VirtualService as healthy. Same overclaim, different layer and language — it wants its own change.
  • Trivy ConfigAuditReport treats an absent summary as all-zero and reports "Pass". Upstream marks summary optional, so this is reachable in principle, but I have not established the standard scanner emits it.
  • getCronJobStatus returns green "Scheduled" for any unsuspended CronJob, which establishes that scheduling is enabled rather than working.
  • The ServiceEntry detail page still shows a status badge above a Location property — the same restatement, but detail pages have the room and a leading status badge is the convention there.

Checked and deliberately left alone: Trivy VulnerabilityReport (all five severity counts are read before "Clean"), Knative Revision (its healthy branches all require Ready=True, and upstream excludes Active from the readiness set), and CAPI's shared phase helper (already falls through to Unknown).

Testing

make tsc clean. 1707 tests pass (77 files), 26 of them new — each pinned to the upstream contract rather than to the implementation: the bindings path and its absence, an explicit rejection, disagreeing workloads, a stale observedGeneration, Kyverno's three not-ready shapes, the delegate VirtualService, and an empty CNPG recovery window.

Visually verified against a live cluster, with the CRDs and fixtures installed to reach every branch: a ServiceMonitor with no status, one accepted, one rejected, one selected by two workloads that disagree, and one where a second workload is still undecided; both VirtualService shapes, both ServiceEntry locations, and three PeerAuthentication modes.

The render confirmed the point of the change — sm-no-status is grey rather than green, sm-rejected is red, and the hostless VirtualService is grey rather than red. The two Accepted by 1/2 rows are visibly different tones (amber for a rejection, grey for an undecided workload) despite identical text.

It also surfaced two things code review did not. A controller reason is arbitrary-length and these kinds could previously only ever render Active, so a rejection reason overflowed its cell by 28px into the next column; the status badge is now bounded to the cell with the full value on hover, matching the pattern the CAPI and Karpenter cells already use. And the w-24 Status column could not fit the new vocabulary — Accepted by 1/2 ellipsised to Accepted by…, losing the count — so the three Prometheus kinds move to w-36. Kyverno's status cell renders a reason the same way and gets the same guard.

🤖 Generated with Claude Code


Note

Low Risk
Display-only status logic in k8s-ui with regression tests; no API or auth changes, though operators may see more unknown/neutral badges instead of false greens.

Overview
Status badges no longer infer “healthy” from spec shape or wrong status paths across Prometheus Operator monitors/rules, Kyverno policies, Istio VirtualService/ServiceEntry, and CNPG ObjectStores.

Prometheus ServiceMonitor, PodMonitor, and PrometheusRule now derive status from status.bindings[].conditions (Accepted), respecting observedGeneration, partial/multi-workload outcomes, and explicit rejections; missing operator status shows Not assessed instead of green Active. Kyverno drops the “has rules ⇒ Ready” fallback. Istio ServiceEntry location labels are neutral; VirtualService treats hostless delegates as unknown, valid declarations as Defined, and still flags missing routes. CNPG ObjectStore requires a real recovery timestamp before Recoverable.

UI tweaks widen Prometheus status columns and truncate long badge text with a title tooltip. New status-overclaim.test.ts pins these contracts.

Reviewed by Cursor Bugbot for commit 38e36ae. Bugbot is set up for automated code reviews on this repo. Configure here.

@nadaverell
nadaverell requested a review from hisco as a code owner September 7, 2026 07:43
@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Prevent unverified Kubernetes status badges from claiming health

🐞 Bug fix 🧪 Tests 🕐 20-40 Minutes

Grey Divider

AI Description

• Read Prometheus acceptance from current per-workload binding conditions.
• Downgrade unverified Kyverno, Istio, and CNPG states to unknown or neutral.
• Add regression coverage for missing, stale, partial, and confirmed evidence.
Diagram

graph TD
  A["Resource Objects"] --> B["Status Accessors"] --> C{"Evidence Quality"} -->|Confirmed| D["Health Badge"] --> G["Resource Table"]
  C -->|Mixed| E["Degraded Badge"] --> G
  C -->|Absent| F["Neutral Badge"] --> G
  C -->|Invalid| H["Unhealthy Badge"] --> G
Loading
High-Level Assessment

The schema-aware accessor changes are the appropriate approach because each resource exposes materially different evidence: per-binding acceptance, controller readiness, declarative mesh classification, or recovery timestamps. A generic health-inference helper was considered but would obscure these distinctions and risk recreating the overclaiming behavior.

Files changed (5) +197 / -33

Bug fix (4) +58 / -33
resource-utils-cnpg.tsRequire a real CNPG recovery point for recoverable status +5/-0

Require a real CNPG recovery point for recoverable status

• Returns an unknown “No recovery point” badge when recovery-window entries contain no recoverability or successful-backup timestamp. Existing backup-failure precedence and confirmed recoverability behavior remain intact.

packages/k8s-ui/src/components/resources/resource-utils-cnpg.ts

resource-utils-istio.tsRemove unsupported health claims from Istio badges +9/-4

Remove unsupported health claims from Istio badges

• Treats hostless VirtualServices as unknown because they may be valid delegates, and labels configured routes as neutrally “Defined.” ServiceEntry location remains visible but becomes neutral because mesh membership does not prove reachability.

packages/k8s-ui/src/components/resources/resource-utils-istio.ts

resource-utils-kyverno.tsStop inferring Kyverno readiness from declared rules +3/-6

Stop inferring Kyverno readiness from declared rules

• Returns “Not assessed” when no definitive Ready condition exists, including Ready=Unknown. Healthy and unhealthy badges remain tied to explicit controller-written Ready outcomes.

packages/k8s-ui/src/components/resources/resource-utils-kyverno.ts

resource-utils-prometheus.tsEvaluate Prometheus configuration acceptance from bindings +41/-23

Evaluate Prometheus configuration acceptance from bindings

• Replaces nonexistent top-level condition checks with per-workload Accepted conditions under status bindings. It ignores stale generations and reports absent, rejected, partially accepted, or fully accepted outcomes accurately across ServiceMonitor, PodMonitor, and PrometheusRule resources.

packages/k8s-ui/src/components/resources/resource-utils-prometheus.ts

Tests (1) +139 / -0
status-overclaim.test.tsAdd regression tests for evidence-based status badges +139/-0

Add regression tests for evidence-based status badges

• Covers Prometheus binding acceptance and stale generations, Kyverno readiness fallbacks, Istio declarative states, and CNPG recovery evidence. The tests also preserve genuine unhealthy outcomes and confirmed healthy states.

packages/k8s-ui/src/components/resources/status-overclaim.test.ts

@qodo-code-review

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (2) 📘 Rule violations (1) 📜 Skill insights (0)

Grey Divider


Action required

1. Unknown workload verdicts look accepted 🐞 Bug ≡ Correctness
Description
getConfigResourceStatus records only Accepted=True and Accepted=False, excluding current
Accepted=Unknown conditions from the aggregate. When one selecting workload reports True while
another reports Unknown, the resource receives a healthy Accepted badge even though not every
workload has accepted it.
Code

packages/k8s-ui/src/components/resources/resource-utils-prometheus.ts[R29-32]

+      if (cond.status === 'True') verdicts.push(true)
+      else if (cond.status === 'False') {
+        verdicts.push(false)
+        if (!rejection) rejection = cond.reason || ''
Relevance

●●● Strong

Recent aggregation precedents accept fixes preventing incomplete condition sets from producing
falsely healthy verdicts.

PR-#1538

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The new helper appends verdicts only for True and False, then returns healthy whenever all
appended values are true. The operator's documented contract explicitly allows Unknown, so a
current unknown report can be omitted from the denominator and hidden by a true report.

packages/k8s-ui/src/components/resources/resource-utils-prometheus.ts[22-52]
packages/k8s-ui/src/components/resources/status-overclaim.test.ts[53-66]
🌐 The Prometheus Operator status proposal states that an Accepted condition may be True, False, or Unknown and that bindings list the selecting workload resources.

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Prometheus configuration status aggregation ignores current `Accepted=Unknown` reports. This allows one `True` report to produce a healthy badge despite another selecting workload remaining unassessed.

## Issue Context
The Prometheus Operator permits `Accepted` condition statuses of `True`, `False`, or `Unknown`. Count every current binding report in the aggregate, while preserving explicit rejection and stale-generation handling; add coverage for mixed `True`/`Unknown` and all-`Unknown` states.

## Fix Focus Areas
- packages/k8s-ui/src/components/resources/resource-utils-prometheus.ts[17-52]
- packages/k8s-ui/src/components/resources/status-overclaim.test.ts[10-67]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

2. Maintainers inherit bug history 📘 Rule violation ⚙ Maintainability
Description
The getServiceMonitorStatus test comment calls the invalid status.conditions read `the original
bug`, embedding change history in source. Anyone maintaining this regression test encounters
historical PR context that can drift independently of the schema behavior documented by the
assertion.
Code

packages/k8s-ui/src/components/resources/status-overclaim.test.ts[30]

+      // Reading this path was the original bug — it is not part of the schema.
Relevance

●●● Strong

Recent repository feedback accepts rewriting comments that describe historical bugs under rule
3036538.

PR-#1649
PR-#1645

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Compliance rule 3036538 prohibits code comments that preserve bug or change history. The added
comment at line 30 explicitly describes the invalid path as the original bug.

Rule 3036538: Disallow references to tickets or PR history in code comments
packages/k8s-ui/src/components/resources/status-overclaim.test.ts[30-30]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Remove the historical bug reference from the test comment while retaining the relevant schema constraint.

## Issue Context
The assertion should explain that `status.conditions` is not part of these resource schemas without describing prior implementation history.

## Fix Focus Areas
- packages/k8s-ui/src/components/resources/status-overclaim.test.ts[30-30]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Empty traffic rules lose their warning 🐞 Bug ≡ Correctness
Description
getVirtualServiceStatus now returns unknown for missing hosts before evaluating totalRoutes. A
hostless object with no HTTP, TCP, or TLS routes therefore bypasses the existing unhealthy `No
Routes` result, although only hostless objects that actually define delegated routes justify the new
exception.
Code

packages/k8s-ui/src/components/resources/resource-utils-istio.ts[35]

+    return { text: 'No Hosts', color: healthColors.unknown, level: 'unknown' }
Relevance

●●● Strong

The PR explicitly preserves No Routes as a real defect; checking routes before hostless handling is
a deterministic fix.

PR-#1649

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Missing route fields are normalized to empty arrays, but the changed host check returns before their
total is examined. The added test covers a hostless object with an HTTP route, while the existing
No Routes assertion covers only an object with hosts, leaving {spec:{}} incorrectly classified
as unknown.

packages/k8s-ui/src/components/resources/resource-utils-istio.ts[24-40]
packages/k8s-ui/src/components/resources/status-overclaim.test.ts[101-117]
🌐 Istio documents that hosts must be empty for a delegate VirtualService and separately defines HTTP, TCP, and TLS route lists as the rules that provide routing behavior.

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The hostless VirtualService exception executes before the route-count validation. Empty hostless objects consequently lose the existing `No Routes` warning.

## Issue Context
Keep valid delegated VirtualServices neutral or unknown when they omit hosts, but continue marking every VirtualService without HTTP, TCP, or TLS routes unhealthy. Add a route-less hostless test alongside the valid hostless delegate case.

## Fix Focus Areas
- packages/k8s-ui/src/components/resources/resource-utils-istio.ts[24-40]
- packages/k8s-ui/src/components/resources/status-overclaim.test.ts[101-118]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Context sources
✅ Compliance rules (platform): 41 rules
✅ Web pages:
  +9 more
Review mode: ⚖️ Balanced: Downgraded extended -> standard: change is below the extended eligibility bar (hunks 10/18, lines 230/200; both must reach the floor). Router rationale: This behavior-changing PR spans five status paths with roughly ten independent edit sites, including nuanced condition aggregation, staleness handling, and multiple controller-specific semantics, making several subtle defects plausible enough to warrant redundant review.

Grey Divider

Tip of the day
💡 Did you know, you can copy the agent prompt from any finding and feed it to your IDE agent

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

})

it(`${name} ignores a status.conditions that these kinds never have`, () => {
// Reading this path was the original bug — it is not part of the schema.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

1. Maintainers inherit bug history 📘 Rule violation ⚙ Maintainability

The getServiceMonitorStatus test comment calls the invalid status.conditions read `the original
bug`, embedding change history in source. Anyone maintaining this regression test encounters
historical PR context that can drift independently of the schema behavior documented by the
assertion.
Agent Prompt
## Issue description
Remove the historical bug reference from the test comment while retaining the relevant schema constraint.

## Issue Context
The assertion should explain that `status.conditions` is not part of these resource schemas without describing prior implementation history.

## Fix Focus Areas
- packages/k8s-ui/src/components/resources/status-overclaim.test.ts[30-30]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +29 to +32
if (cond.status === 'True') verdicts.push(true)
else if (cond.status === 'False') {
verdicts.push(false)
if (!rejection) rejection = cond.reason || ''

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

2. Unknown workload verdicts look accepted 🐞 Bug ≡ Correctness

getConfigResourceStatus records only Accepted=True and Accepted=False, excluding current
Accepted=Unknown conditions from the aggregate. When one selecting workload reports True while
another reports Unknown, the resource receives a healthy Accepted badge even though not every
workload has accepted it.
Agent Prompt
## Issue description
Prometheus configuration status aggregation ignores current `Accepted=Unknown` reports. This allows one `True` report to produce a healthy badge despite another selecting workload remaining unassessed.

## Issue Context
The Prometheus Operator permits `Accepted` condition statuses of `True`, `False`, or `Unknown`. Count every current binding report in the aggregate, while preserving explicit rejection and stale-generation handling; add coverage for mixed `True`/`Unknown` and all-`Unknown` states.

## Fix Focus Areas
- packages/k8s-ui/src/components/resources/resource-utils-prometheus.ts[17-52]
- packages/k8s-ui/src/components/resources/status-overclaim.test.ts[10-67]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

// evidence of a fault.
if (hosts.length === 0) {
return { text: 'No Hosts', color: healthColors.unhealthy, level: 'unhealthy' }
return { text: 'No Hosts', color: healthColors.unknown, level: 'unknown' }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

3. Empty traffic rules lose their warning 🐞 Bug ≡ Correctness

getVirtualServiceStatus now returns unknown for missing hosts before evaluating totalRoutes. A
hostless object with no HTTP, TCP, or TLS routes therefore bypasses the existing unhealthy `No
Routes` result, although only hostless objects that actually define delegated routes justify the new
exception.
Agent Prompt
## Issue description
The hostless VirtualService exception executes before the route-count validation. Empty hostless objects consequently lose the existing `No Routes` warning.

## Issue Context
Keep valid delegated VirtualServices neutral or unknown when they omit hosts, but continue marking every VirtualService without HTTP, TCP, or TLS routes unhealthy. Add a route-less hostless test alongside the valid hostless delegate case.

## Fix Focus Areas
- packages/k8s-ui/src/components/resources/resource-utils-istio.ts[24-40]
- packages/k8s-ui/src/components/resources/status-overclaim.test.ts[101-118]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

@nadaverell
nadaverell force-pushed the fix/status-badges-claiming-unverified-health branch 2 times, most recently from 361df2f to 38e36ae Compare September 7, 2026 11:10
Six status cells painted a row green, or red, on evidence the object does
not carry.

ServiceMonitor, PodMonitor and PrometheusRule read `status.conditions`,
a path their schema does not define. The operator reports acceptance per
selecting workload under `status.bindings[].conditions`, the only
condition type is `Accepted`, and it writes any of it only when the
StatusForConfigurationResources feature gate is on. The read therefore
never matched and every row fell through to a green "Active" — including
a configuration a Prometheus had explicitly rejected. They now read the
real path, report the rejection, distinguish workloads that disagree,
skip a condition observed against an older generation, and say nothing
when nothing reported.

A Kyverno policy counted its own declared rules as readiness. Kyverno
writes a Ready condition; declaring rules is not the controller
accepting them.

Istio ServiceEntry rendered `spec.location` in green. External and
Internal describe where a host sits relative to the mesh, not whether it
resolves or answers. VirtualService called any object with hosts and
routes "Active", and called every hostless one broken — but a delegate
VirtualService is required to declare no hosts, and nothing on the object
says whether a parent delegates to it.

A CNPG ObjectStore reported "Recoverable" from the presence of a server
entry. Every timestamp on a recovery window is optional, so a server can
be listed while holding nothing restorable.

Badges take their colour from the returned `color`, so each of these
changes the colour alongside the level; changing the level alone would
have left the row green.
@nadaverell
nadaverell force-pushed the fix/status-badges-claiming-unverified-health branch from 38e36ae to 7b527b5 Compare September 7, 2026 11:29
@nadaverell
nadaverell merged commit efa7bf2 into main Sep 7, 2026
9 checks passed
@nadaverell
nadaverell deleted the fix/status-badges-claiming-unverified-health branch September 7, 2026 12:41
nadaverell added a commit that referenced this pull request Sep 7, 2026
A topology node's status is not just a dot in the graph. MCP builds its
`get_topology` problem list from degraded and unhealthy nodes
(`internal/mcp/tools.go:1645`), and GitOps derives managed-resource
health from it (`pkg/gitops/tree/graph.go:21`). A green node is Radar
answering *"nothing is wrong"* — to an operator scanning the graph, and
to an AI asked whether the cluster is healthy.

Three places were answering that without checking. This continues
#1645/#1649/#1659, which fixed the TypeScript accessors; `pkg/topology`
computes health independently in Go and had drifted from them.

## What changed

**Gateway API routes were judged on `Accepted` alone.** A gateway
accepting a route says nothing about whether the backends it forwards to
exist — so an HTTPRoute whose `backendRef` named a deleted Service
reported healthy. That is precisely the failure an operator opens the
graph to find, and it is the same defect #1645 fixed on the TS side.
`getRouteHealth` now reads `ResolvedRefs` alongside `Accepted`.

A route reads healthy only when every parent reports both
`Accepted=True` and `ResolvedRefs=True`. A parent that reports nothing —
no conditions, or only ones observed against an earlier `generation` —
is counted as unassessed and blocks both the healthy and the unhealthy
verdict, so neither a green node nor a total failure is claimed while a
gateway is still silent.

Two deliberate differences from the TS accessor, since this is otherwise
meant to match it: the `observedGeneration` staleness check is an
addition (`speaksToCurrentSpec` in `resource-utils.ts:1953` only tests
whether the relevant conditions exist), and a route whose parents all
report nothing reads `unknown` here where TS reads `Pending`/degraded —
claiming a fault we have not established seemed the wrong direction for
this change. Both are candidates for reconciling in TS rather than
divergences to keep.

**HorizontalPodAutoscalers were hardcoded healthy** while the builder
read `hpa.Status.CurrentReplicas` three lines below. `pkg/hpadiag`
already diagnoses HPAs for the detail drawer, so the node now uses it
and the graph stops disagreeing with the drawer.

An autoscaler the controller has not written a condition for reads
unknown rather than healthy: `hpadiag.Analyze` reports `ok` for a
conditionless HPA, so absence of evidence would otherwise arrive as
health. States the vocabulary calls intentionally idle — scaled to zero,
disabled, pinned, stabilised, at minimum — map to neutral, matching the
shipped TS mapping.

Otherwise the mapping only ever *downgrades* on negative evidence —
unreachable metrics and inability to scale become unhealthy,
capped/incomplete metrics degraded, everything else stays healthy.
Notably `scaling_up`/`scaling_down` remain healthy: an HPA doing its job
is not a problem, and since degraded nodes become MCP-reported problems,
promoting routine autoscaling would manufacture false ones. This differs
from `hpaStateLevel` in `resource-utils-hpa.ts:68`, which calls both
scaling directions degraded — worth reconciling, but in both places
deliberately rather than by divergence here.

**Four Secret nodes are stubs synthesised from a reference** — a TLS or
CA secret named by a Traefik or HTTPProxy resource that the Secret
listing never produced, either because it does not exist or because it
was not listable. They were built green, so a misspelled
`certificateRef` rendered as a healthy Secret. They now report unknown.

## Verification

The HPA fix is verified end to end against a real controller failure
rather than a hand-written fixture: a `kind` cluster has no
metrics-server, so an HPA there genuinely gets `ScalingActive=False /
FailedGetResourceMetric` from the HPA controller. With that in the
cluster, the topology API returns `status: "unhealthy"` for the node,
and `get_topology` over MCP returns the same — where both previously
said healthy.

Route health is pinned by table-driven tests covering unresolved
backendRefs, full acceptance, partial and total rejection, absent
reports, a stale `observedGeneration`, and the mixed cases where one
parent is current and healthy while another is stale or silent. I
confirmed these actually fail against the previous algorithm rather than
passing either way: for a route with `Accepted=True, ResolvedRefs=False`
the old code returns `healthy` and the new code returns `degraded`.

`go build` and `go test ./...` clean in both the root and `pkg` modules.

## Scope

Deliberately narrow. A survey found 22 hardcoded `Status: StatusHealthy`
node constructions, but most are not defects worth changing:

- **Recolouring config objects** (ConfigMap, listed Secrets,
ServiceAccount, the synthetic Internet node) is not done.
`StatusNeutral` means *intentionally idle* and paints a sky-blue outline
(`K8sResourceNode.tsx:275`), so it would add emphasis to the least
interesting nodes rather than remove a claim. That needs a deliberate
"health not applicable" presentation instead.
- **NetworkPolicy and the spec-presence-implies-healthy sites**
(VirtualService, Istio Gateway, Traefik routes) are consistency gaps,
not defects anyone is misled by.
- **Ingress `loadBalancer` and Service endpoints** would be *new* health
signals rather than removing a false one. Given that a wrong status
propagates into MCP problems and GitOps degraded counts, a false alarm
costs more than a missing one here, and Service semantics already live
in `internal/k8s/detect.go:1151`.

Known and not addressed: `TraefikService` is marked unhealthy on zero
backends in the resources view (`builder.go:2274`) but always healthy in
the traffic view (`:6472`); Cilium and ClusterNetworkPolicy expose
conditions the builder ignores; VPA conditions are readable and
`ConfigUnsupported` currently renders green; and the generic condition
helper (`builder.go:7951`) returns on the first
Ready/Available/Succeeded it sees.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Changes how topology health is computed for routes, HPAs, and secrets,
which can alter MCP-reported problems and GitOps degraded counts; logic
is conservative but user-visible status may shift.
> 
> **Overview**
> Topology nodes were marking **healthy** without checking real status,
which flows into MCP problem lists and GitOps health. This PR tightens
three cases in `pkg/topology/builder.go`.
> 
> **Gateway API routes** — `getRouteHealth` no longer treats
`Accepted=True` as fully healthy. It also requires current
`ResolvedRefs` (and ignores conditions on stale `observedGeneration`),
counts silent parents as unassessed, and only returns healthy when every
reporting parent accepts and resolves refs.
> 
> **HPAs** — HPA graph nodes call new `hpaNodeHealth`, which maps
`hpadiag.Analyze` results into graph status (unhealthy/degraded for
scale/metrics failures, neutral for intentional idle states, unknown
when unreconciled). Routine scaling stays non-problematic so MCP does
not get false alarms.
> 
> **Referenced Secret stubs** (Traefik/Contour TLS/CA refs not in the
Secret list) switch from **healthy** to **unknown** so missing secrets
are not shown as OK.
> 
> Adds `node_health_test.go` table tests for route and HPA health
behavior.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
edf6a40. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant