Skip to content

Commit 1e94aaf

Browse files
authored
Detail pages and logs fit at laptop widths; Pending Pods and scheduling causes read plainly (#2074)
Carved out of #1922. This is the shared detail-page UI from that PR: every resource kind gets it, so it is reviewed here on its own rather than inside a CloudNativePG change. Merge before #1922, whose CloudNativePG pages pass the opt-in header props carried here. ## What users see ### Expanded view: tabs on one line at laptop widths At 1280px the tab strip used to wrap the actions bar onto two rows and leave Rollback or Delete on a line of their own. Tabs now drop their icons only while they don't fit, and the strip keeps the active tab in view. Scope controls wrap instead of pushing tabs out, and the tabs get a focus ring. The actions bar puts object actions (Set image, Restart, Rollback) and utility actions (compare, delete) in two groups that wrap as groups. Before / after, Deployment at 1280×800: ![Before: expanded Deployment at 1280](https://github.com/user-attachments/assets/7bb74433-9ed3-4936-9166-837711a07b2a) ![After: expanded Deployment at 1280](https://github.com/user-attachments/assets/eb779b69-d3ff-46f1-ab35-725d5a92f18e) ### Drawer header: unchanged #1922 puts the drawer's name on the title line beside the header buttons. That saves a line for short names, but when the labelled Investigate button shows, the name gets about 300px: a Pod name such as `su-unschedulable-7d98c9cc76-4rdf7` was cut to `su-unschedulable-7d98c9cc76…`. So the drawer keeps main's layout here: kind and status beside the controls, then the name on its own full-width line. The hooks #1922's pages use there still work: a host's status badge, and object actions in the actions bar. ### Pending Pods say what is actually going on - A container that hasn't started reads **Not started** in neutral grey, instead of red **Not Ready** / **unknown** or amber **Pending**. Only a reported failure (a non-zero exit, or a waiting reason such as `ImagePullBackOff` or `CrashLoopBackOff`) is red; `ContainerCreating` and `PodInitializing` stay neutral. - When the metrics API answers but no container, init containers included, has run yet, Resource Usage says **No usage yet: this Pod is not running** instead of **Metrics unavailable. Radar cannot read metrics.k8s.io.** The Pod metrics history response gains `metricsAPIReachable`. - The drawer's issue row gives the scheduler's reasons in plain words on their own line, every cause kept, with the raw message in the details. Before, the row cut the cause off after a few words. Before / after, unschedulable Pod with an init container (the cluster's metrics-server is healthy): ![Before: Pending Pod drawer](https://github.com/user-attachments/assets/50ce5a17-09c8-42af-a962-8e65e3399867) ![After: Pending Pod drawer](https://github.com/user-attachments/assets/37db6ff9-9850-46dd-bac9-f88fcbd95b65) ### Logs - At 1280px the toolbar used to squeeze its labels ("1/1 pods" and the level chips broke inside the label) and push Export and Clear off screen. Display and utility controls now wrap onto a second row as a group. - Export and Clear are disabled while nothing is loaded. - The capture time and any notice share one status line. - Long lines break between words before breaking inside one, a structured line keeps `{N fields}` whole, and a CloudNativePG PostgreSQL record shows the database's message. - `WorkloadLogsViewer` gains `initialContainer`, plus opt-ins `emptySourceState` and `disableSourceControlsWithoutSource`. ![Before: workload logs toolbar at 1280](https://github.com/user-attachments/assets/bfc9bb58-196b-4c79-ada1-985440c4c6bf) ![After: workload logs toolbar at 1280](https://github.com/user-attachments/assets/9c3f2ba1-19c9-443c-aa84-64afe0c69237) ### Issue titles Issue rows on Home and the Issues page take the reason's title when the category would name the wrong operation. Today that is CloudNativePG WAL archiving and last-backup issues, which main titles "Backup failed". ### Namespace switch No visible change on a view-filter switch: both builds send the same six requests after picking a namespace. What changes: - the switch waits 15s instead of 5s; - after a client timeout, a view-filter switch reads the server's scope and reports success if the pick was applied; - a cache-scoped switch (`--namespace-scope`) refreshes every query except the scope it just wrote, without cancelling refetches in flight. ### Timeline A Pod row is tagged by the state the change produced: unhealthy when a container just exited with an error, degraded from the moment the Pod lost readiness until it returns. Before, it waited out the standing-state grace periods. A clean container exit still reads as completion, and a Pod still starting up keeps its grace. ### Smaller fixes - **Sidebar:** a filter keeps a category's matching workspace views (all of them when the term names the category or an API group). Arrow keys and Enter reach them. The heading reads **Views** instead of **Workspace**. Today only the CloudNativePG workspace has views. - **Row menus:** Escape closes the menu without also closing the drawer. - **YAML:** the Edit button uses the accent token instead of `text-blue-*`. - **Logging:** a namespace name is no longer used as a console format string. ## Radar Hub and library consumers All changes to `@skyhook-io/k8s-ui` and `@skyhook-io/radar-app` are additive: - `WorkloadView` gains the opt-in host props `titlePrefix`, `inlineBadges`, `namespaceNote`, `renderStatusBadge`, `statusNote`, `hideKindBadge`, `subheader`, `tabOrder`, `specTab` and `renderHeaderActions`. `WorkloadExtraTab` gains `badge`. - `DetailShell` gains `subheader` and `wrapHeader`; `ResourceActionsBar` gains `leadingActions`. - `IssueRow` and `ResourceIssuesSection` gain `compact`. `LogCore.emptyMessage` widens from `string` to `ReactNode`. `MetricsUnavailableNotice` gains `noUsageYet`. - New package exports: `issueTitle`, `issueReasonTitle`, `tokenizeJson`. `summarizeSchedulerMessage` gains an optional `{ plain }` argument. **Version skew.** Hub runs the newest UI against older Radars: - `metricsAPIReachable` is absent there, so a Pending Pod keeps the old metrics notice. - An issue without a decomposed `cause` falls back to the raw scheduler message. The plain-words summary handles that too (see the deviations). The expanded header and tab strip change for Hub users exactly as they do in Radar. `radar-app` now imports `issueTitle` from `k8s-ui`. This ships in `k8s-ui` 1.17.0, which the peer floor (`>=1.17.0`) already requires. ## Deviations from #1922 Code is copied verbatim from #1922 at `01f2a97ab` except: - **The drawer header keeps main's layout** (owner decision; see [above](#drawer-header-unchanged)). The renderStatusBadge hook, `leadingActions` and the non-shrinking utility buttons stay, and the drawer case in `WorkloadView.spec.test.tsx` now checks that the name has its own line. - `ResourcesSidebar.tsx` leaves out `countLowerBound` and its test. It needs `facts/certaintyGlyph`, which stays in #1922. - `status-tone.tsx` carries only `toneTextClass`, which the logs status line uses. `toneFillClass`, `worseTone` and their exports stay in #1922. - `server.go` carries only the `metricsAPIReachable` hunk. The CNPG routes and the move to `internal/integration` stay. `server_smoke_test.go` carries `TestPodMetricsHistoryAPIReachability`. - `client.ts` carries `podMetricsQuery`, `metricsAPIReachable` and the namespace-switch hunks. `useIssues(enabled)`, `useSubjectIssues`, the memoized `useRadarFeature`, the new PVC usage statuses and `ApplyResourceResult.uid` stay; they belong to the other carve-outs or to CNPG. - The work order's inventory didn't list three files, but they are what makes the plain scheduling cause reach users, so this PR carries them: `IssuesView.tsx` and `ResourceIssuesSection.tsx` (with its test), and the one-line `compact={!expanded}` in web `WorkloadView.tsx`. - `summarizeSchedulerMessage(…, { plain: true })` also handles the scheduler phrasing in current Kubernetes. On EKS 1.34 the message reads `6 node(s) had untolerated taint(s), 8 node(s) didn't match Pod's node affinity/selector. no new claims to deallocate, preemption: …`, and #1922's version returned `6 node(s) had untolerated taint(s); 8 node(s) didn't match Pod's node affinity/selector. no new claims to deallocate; ` (raw clauses and a trailing `; `). It now returns `6 nodes have taints the Pod does not tolerate; 8 nodes do not match the Pod’s node affinity or selector`. The test uses that message. The phrase is removed with a plain match: CodeQL flagged the first version's `\s*` as polynomial on long runs of spaces. - Messages are split into clauses in one pass. #1922's lookahead (`/[,;](?![^\[\]{}]*[\]}])/`) rescanned the rest of the message at every comma, so a 240 KB custom-scheduler message took about 7 s and blocked the drawer. The one-pass split keeps the same rule: no split where the next bracket ahead closes. A test covers a 10,000-clause message. - "No usage yet" also checks init containers. #1922 looked only at regular containers, so a Pending Pod running its init container read "this Pod is not running". Test added in `PodRenderer.test.tsx`. - Timeline readiness stays degraded until it returns. In #1922 only the row where readiness dropped was degraded. A later change while the Pod was still unready (for example, attaching a debug container) fell back to the 5-minute standing-state grace and read healthy, which ended the incident early. Readiness now counts as lost when the Ready condition went false after a running container had started; at startup it goes false first, so the startup grace stays. One case in #1922's test described a Pod that had run for an hour and just lost readiness while calling it startup; it now uses a Pod that is actually starting. New cases cover a later unready change and a Pod first seen after losing readiness. A clean exit (code 0) also no longer counts as completion when the Pod restarts its containers (`restartPolicy: Always`), and a restarted container's previous run counts as proof that readiness was lost. Before, a liveness restart that exited cleanly turned the row green while the Pod was still unready. - `WorkloadView` asks a host's `renderStatusBadge` for a badge only once the resource has loaded. In #1922 it was called with `undefined` during loading: a callback that reads fields throws, and the CloudNativePG Cluster badge read "Phase Not reported" until the object arrived. Test: `waits for the resource before asking a host for its status badge`. - The timeout reconcile applies only to view-filter switches. A `--namespace-scope` rescope publishes its target before the cache rebuild finishes (`reinitializeNamespaceScope` sets the override before `InitAllSubsystems`) and can still roll back, so a matching scope doesn't prove it succeeded. Test: `client.namespaceSwitch.hook.test.tsx`. ## Left in #1922 The drawer title-line layout stays there until it adapts to long names. The rest is opt-in only or CloudNativePG-specific: - `AreaChart` (`shadedRanges`), `CreateResourceDialog` (`onBack`, `initialMode`, `lockMode`), `DockContext` / `TerminalTab` (`shell`, `sessionNote`), `AlertBanner.action`, `.btn-secondary`; - the DatabaseRole renderer dispatch, `ResourcesView.renderKindView` and the web wiring to `web/src/integrations`; - the `facts`, `problems`, `FoldSection`, `RefLink`, `grant` and `ActionConfirmDialog` components; - the `stripTrailingSlashes` refactor and DESIGN.md's facts section. ## Testing - `go build ./... && go test ./...`, `make tsc`, k8s-ui `tsc` + vitest (245 files, 4,486 tests), web vitest (167 files, 1,998 tests), and `npm run lint` all pass. Lint reports 0 errors and the same 489 warnings as main. - **Before/after on a live EKS 1.34 cluster.** Main and this branch ran side by side against the same cluster at 1280×800 and 1920×1080, in light and dark. Captures: - a Deployment, drawer and expanded; - an unschedulable Pod (a fixture with an init container) and a running Pod; - a Service; - an uncurated CRD (`ClickHouseInstallation`); - workload and single-Pod logs, including a workload with no running Pod; - the namespace switch. All captures showed the "What users see" changes above. Export and Clear were disabled on the empty logs view, and the namespace switch sent identical requests on both builds. - Not exercised live: - the sidebar views filter, whose only views are on the CloudNativePG workspace page (the keyboard tests cover it); - the timeline health tags (Go tests); - the reconcile after a client timeout (`client.namespaceSwitch.test.ts`). ## After this merges #1922 merges main and takes main's version of every file here, including main's drawer header in `WorkloadView.tsx` (owner decision): it does not re-apply its title-line drawer layout. Its own changes to `ResourcesSidebar.tsx` (`countLowerBound`), `status-tone.tsx`, `server.go`, `client.ts` and web `WorkloadView.tsx` go back on top. It also picks up the scheduler-summary fix above.
1 parent 6713f57 commit 1e94aaf

46 files changed

Lines changed: 1714 additions & 266 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎internal/k8s/cache.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -916,7 +916,7 @@ func recordToTimelineStore(clusterContext, kind, namespace, name, uid, op string
916916
}
917917
labels := entry.Labels
918918
createdAt := entry.CreatedAt
919-
healthState := classifyTimelineHealth(kind, obj, time.Now())
919+
healthState := classifyTimelineChangeHealth(kind, oldObj, obj, time.Now())
920920

921921
// Feed the tombstone on every add/update/delete. While the object is live
922922
// this mirrors its enrichment; once it is gone (delete, or a late K8s event

‎internal/k8s/timeline_health.go‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,72 @@ func levelToTimeline(l health.Level) timeline.HealthState {
4949
return timeline.HealthUnknown
5050
}
5151
}
52+
53+
// classifyTimelineChangeHealth labels one recorded change. The canonical
54+
// classifier judges standing state, so it holds back on a fresh failure (a
55+
// readiness grace, a restart threshold); a timeline row instead labels the
56+
// moment it records, and a Pod whose container just exited with an error, or
57+
// that just lost readiness, is not healthy at that moment.
58+
func classifyTimelineChangeHealth(kind string, oldObj, newObj any, now time.Time) timeline.HealthState {
59+
pod, ok := newObj.(*corev1.Pod)
60+
if kind != "Pod" || !ok {
61+
return classifyTimelineHealth(kind, newObj, now)
62+
}
63+
level := health.PodDisplayLevel(pod, now)
64+
if pod.Status.Phase != corev1.PodRunning || pod.DeletionTimestamp != nil {
65+
return levelToTimeline(level)
66+
}
67+
completing := false
68+
for _, cs := range pod.Status.ContainerStatuses {
69+
if t := cs.State.Terminated; t != nil {
70+
if t.ExitCode != 0 {
71+
level = health.WorseOf(level, health.LevelUnhealthy)
72+
} else if pod.Spec.RestartPolicy != corev1.RestartPolicyAlways {
73+
// Under restartPolicy Always a clean exit is a restart, not completion.
74+
completing = true
75+
}
76+
}
77+
}
78+
// A container finishing cleanly also drops readiness; that is completion.
79+
// Lost readiness stays degraded on every later row until it returns, rather
80+
// than reading healthy again inside the standing-state grace.
81+
if !completing && !timelinePodReady(pod) {
82+
if old, ok := oldObj.(*corev1.Pod); (ok && old != pod && timelinePodReady(old)) || timelinePodLostReadiness(pod) {
83+
level = health.WorseOf(level, health.LevelDegraded)
84+
}
85+
}
86+
return levelToTimeline(level)
87+
}
88+
89+
// timelinePodLostReadiness reports readiness that went false after a container
90+
// had started, in its current run or the one before a restart: lost, not
91+
// still to come at startup.
92+
func timelinePodLostReadiness(p *corev1.Pod) bool {
93+
var since time.Time
94+
for _, c := range p.Status.Conditions {
95+
if c.Type == corev1.PodReady && c.Status == corev1.ConditionFalse {
96+
since = c.LastTransitionTime.Time
97+
}
98+
}
99+
if since.IsZero() {
100+
return false
101+
}
102+
for _, cs := range p.Status.ContainerStatuses {
103+
if r := cs.State.Running; r != nil && !r.StartedAt.IsZero() && r.StartedAt.Time.Before(since) {
104+
return true
105+
}
106+
if t := cs.LastTerminationState.Terminated; t != nil && !t.StartedAt.IsZero() && t.StartedAt.Time.Before(since) {
107+
return true
108+
}
109+
}
110+
return false
111+
}
112+
113+
func timelinePodReady(p *corev1.Pod) bool {
114+
for _, c := range p.Status.Conditions {
115+
if c.Type == corev1.PodReady {
116+
return c.Status == corev1.ConditionTrue
117+
}
118+
}
119+
return false
120+
}

‎internal/k8s/timeline_health_test.go‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,3 +149,60 @@ func TestClassifyTimelineHealthWorkloads(t *testing.T) {
149149
})
150150
}
151151
}
152+
153+
func TestClassifyTimelineChangeHealthPod(t *testing.T) {
154+
now := time.Now()
155+
readyAt := func(v corev1.ConditionStatus, at time.Time) []corev1.PodCondition {
156+
return []corev1.PodCondition{{Type: corev1.PodReady, Status: v, LastTransitionTime: metav1.NewTime(at)}}
157+
}
158+
ready := func(v corev1.ConditionStatus) []corev1.PodCondition { return readyAt(v, now) }
159+
runningAt := func(at time.Time) corev1.ContainerState {
160+
return corev1.ContainerState{Running: &corev1.ContainerStateRunning{StartedAt: metav1.NewTime(at)}}
161+
}
162+
running := runningAt(now.Add(-time.Hour))
163+
pod := func(conds []corev1.PodCondition, state corev1.ContainerState, restarts int32) *corev1.Pod {
164+
return &corev1.Pod{
165+
ObjectMeta: metav1.ObjectMeta{CreationTimestamp: metav1.NewTime(now.Add(-time.Hour))},
166+
Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "postgres", ReadinessProbe: &corev1.Probe{}}}},
167+
Status: corev1.PodStatus{
168+
Phase: corev1.PodRunning, Conditions: conds,
169+
ContainerStatuses: []corev1.ContainerStatus{{Name: "postgres", State: state, RestartCount: restarts}},
170+
},
171+
}
172+
}
173+
errored := corev1.ContainerState{Terminated: &corev1.ContainerStateTerminated{Reason: "Error", ExitCode: 1}}
174+
completed := corev1.ContainerState{Terminated: &corev1.ContainerStateTerminated{Reason: "Completed", ExitCode: 0}}
175+
policy := func(p *corev1.Pod, rp corev1.RestartPolicy) *corev1.Pod { p.Spec.RestartPolicy = rp; return p }
176+
restarted := func(p *corev1.Pod) *corev1.Pod {
177+
p.Status.ContainerStatuses[0].LastTerminationState = corev1.ContainerState{Terminated: &corev1.ContainerStateTerminated{ExitCode: 0, StartedAt: metav1.NewTime(now.Add(-time.Hour))}}
178+
return p
179+
}
180+
cases := []struct {
181+
name string
182+
old, new *corev1.Pod
183+
want timeline.HealthState
184+
}{
185+
{"container exited with an error", pod(ready(corev1.ConditionTrue), running, 0), pod(ready(corev1.ConditionFalse), errored, 1), timeline.HealthUnhealthy},
186+
{"lost readiness while running", pod(ready(corev1.ConditionTrue), running, 0), pod(ready(corev1.ConditionFalse), running, 0), timeline.HealthDegraded},
187+
{"clean completion is not a failure", pod(ready(corev1.ConditionTrue), running, 0), pod(ready(corev1.ConditionFalse), completed, 0), timeline.HealthHealthy},
188+
{"regained readiness", pod(ready(corev1.ConditionFalse), running, 0), pod(ready(corev1.ConditionTrue), running, 0), timeline.HealthHealthy},
189+
{"added while not ready yet keeps the startup grace", nil, pod(readyAt(corev1.ConditionFalse, now.Add(-40*time.Second)), runningAt(now.Add(-30*time.Second)), 0), timeline.HealthHealthy},
190+
{"starting and still not ready keeps the startup grace", pod(readyAt(corev1.ConditionFalse, now.Add(-40*time.Second)), runningAt(now.Add(-30*time.Second)), 0), pod(readyAt(corev1.ConditionFalse, now.Add(-40*time.Second)), runningAt(now.Add(-30*time.Second)), 0), timeline.HealthHealthy},
191+
{"a later change while readiness is still lost stays degraded", pod(readyAt(corev1.ConditionFalse, now.Add(-2*time.Minute)), running, 0), pod(readyAt(corev1.ConditionFalse, now.Add(-2*time.Minute)), running, 0), timeline.HealthDegraded},
192+
{"added after readiness was lost is degraded", nil, pod(readyAt(corev1.ConditionFalse, now.Add(-2*time.Minute)), running, 0), timeline.HealthDegraded},
193+
{"a clean exit completes a Job Pod", pod(ready(corev1.ConditionTrue), running, 0), policy(pod(ready(corev1.ConditionFalse), completed, 0), corev1.RestartPolicyOnFailure), timeline.HealthHealthy},
194+
{"a clean exit in a restarting Pod is not completion", pod(ready(corev1.ConditionTrue), running, 0), policy(pod(ready(corev1.ConditionFalse), completed, 0), corev1.RestartPolicyAlways), timeline.HealthDegraded},
195+
{"restarted after losing readiness and still not ready stays degraded", policy(restarted(pod(readyAt(corev1.ConditionFalse, now.Add(-time.Minute)), runningAt(now.Add(-30*time.Second)), 1)), corev1.RestartPolicyAlways), policy(restarted(pod(readyAt(corev1.ConditionFalse, now.Add(-time.Minute)), runningAt(now.Add(-30*time.Second)), 1)), corev1.RestartPolicyAlways), timeline.HealthDegraded},
196+
}
197+
for _, tc := range cases {
198+
t.Run(tc.name, func(t *testing.T) {
199+
var old any
200+
if tc.old != nil {
201+
old = tc.old
202+
}
203+
if got := classifyTimelineChangeHealth("Pod", old, tc.new, now); got != tc.want {
204+
t.Errorf("got %q, want %q", got, tc.want)
205+
}
206+
})
207+
}
208+
}

‎internal/server/server.go‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3488,7 +3488,12 @@ func (s *Server) handlePodMetricsHistory(w http.ResponseWriter, r *http.Request)
34883488
s.writeJSON(w, history)
34893489
}
34903490

3491-
func podMetricsHistoryResponse(ctx context.Context, history *k8s.PodMetricsHistory, namespace, name string, health k8s.MetricsCollectionHealth, includeAPIServiceConditionMessage bool) *k8s.PodMetricsHistory {
3491+
type podMetricsHistoryWithAvailability struct {
3492+
*k8s.PodMetricsHistory
3493+
MetricsAPIReachable bool `json:"metricsAPIReachable"`
3494+
}
3495+
3496+
func podMetricsHistoryResponse(ctx context.Context, history *k8s.PodMetricsHistory, namespace, name string, health k8s.MetricsCollectionHealth, includeAPIServiceConditionMessage bool) *podMetricsHistoryWithAvailability {
34923497
if history == nil {
34933498
history = &k8s.PodMetricsHistory{
34943499
Namespace: namespace,
@@ -3499,7 +3504,10 @@ func podMetricsHistoryResponse(ctx context.Context, history *k8s.PodMetricsHisto
34993504
if health.PodMetrics.ConsecutiveErrors > 0 {
35003505
history.CollectionError, history.RawCollectionError, history.MetricsUnavailableDiagnosis, history.MetricsUnavailable = metricsHistoryCollectionError(ctx, "Pod", health.PodMetrics.LastError, includeAPIServiceConditionMessage)
35013506
}
3502-
return history
3507+
return &podMetricsHistoryWithAvailability{
3508+
PodMetricsHistory: history,
3509+
MetricsAPIReachable: health.PodMetrics.LastSuccess != "" && health.PodMetrics.ConsecutiveErrors == 0,
3510+
}
35033511
}
35043512

35053513
// handleNodeMetricsHistory returns historical metrics for a specific node

‎internal/server/server_smoke_test.go‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2055,3 +2055,17 @@ func TestSmokeChangesExactGroup(t *testing.T) {
20552055
t.Fatalf("Volcano Job history = %v, want only its own versioned rows", got)
20562056
}
20572057
}
2058+
2059+
func TestPodMetricsHistoryAPIReachability(t *testing.T) {
2060+
for _, tc := range []struct {
2061+
lastSuccess string
2062+
failures int
2063+
reachable bool
2064+
}{{"", 0, false}, {"2026-10-06T00:00:00Z", 0, true}, {"2026-10-06T00:00:00Z", 1, false}} {
2065+
health := k8s.MetricsCollectionHealth{PodMetrics: k8s.MetricsSourceHealth{LastSuccess: tc.lastSuccess, ConsecutiveErrors: tc.failures, LastError: "transport failed"}}
2066+
got := podMetricsHistoryResponse(context.Background(), nil, "default", "nginx", health, false)
2067+
if got.MetricsAPIReachable != tc.reachable {
2068+
t.Fatalf("%+v: %+v", tc, got)
2069+
}
2070+
}
2071+
}

‎packages/k8s-ui/src/components/issues/IssuesView.tsx‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { summarizeSchedulerMessage } from '../resources/resource-utils'
12
import { useMemo, useState, type ComponentType, type ReactNode } from 'react';
23
import { AlertOctagon, AlertTriangle, ArrowRight, CircleCheck, Clock, ExternalLink, Layers, Terminal, Workflow } from 'lucide-react';
34
import { CardBody, CardSection, ClusterName, EmptyState, KIND_CHIP_CLASS, TerminalBlock } from '../ui';
@@ -13,7 +14,7 @@ import {
1314
ISSUE_SEVERITY_RAIL_CLASS,
1415
ISSUE_SEVERITY_SOLID_CLASS,
1516
ISSUE_SEVERITY_TEXT_CLASS,
16-
categoryLabel,
17+
issueTitle,
1718
groupBadgeClass,
1819
groupLabel,
1920
} from './severity';
@@ -168,6 +169,7 @@ export interface IssueRowProps {
168169
resourceHref?: (ref: IssueResourceRef) => string;
169170
onResourceClick?: (ref: IssueResourceRef) => void;
170171
as?: 'li' | 'div';
172+
compact?: boolean;
171173
className?: string;
172174
dimmed?: boolean;
173175
/** Suppress the "Subject" deep-link in the expanded body — set by hosts that
@@ -195,6 +197,7 @@ export function IssueRow({
195197
resourceHref,
196198
onResourceClick,
197199
as = 'li',
200+
compact = false,
198201
className,
199202
dimmed,
200203
hideSubject,
@@ -208,6 +211,7 @@ export function IssueRow({
208211
const cluster = clusterLabel?.(issue);
209212
const affected = affectedSummary(issue.affected);
210213
const { headline } = issueMessageParts(issue);
214+
const schedulingCause = compact && issue.reason === 'Unschedulable' ? summarizeSchedulerMessage(issue.cause || issue.message || headline || '', { plain: true }) : undefined;
211215
const { panelId, buttonProps } = useDisclosure(open);
212216
const Container = as;
213217
const severity = normalizeIssueSeverity(issue.severity);
@@ -289,15 +293,15 @@ export function IssueRow({
289293

290294
<div className="flex min-w-0 flex-1 flex-col gap-1">
291295
<div className="flex min-w-0 items-baseline gap-2">
292-
<span className="min-w-0 truncate text-sm font-medium text-theme-text-primary">{categoryLabel(issue.category)}</span>
296+
<span className="min-w-0 truncate text-sm font-medium text-theme-text-primary">{issueTitle(issue)}</span>
293297
<span className={`shrink-0 self-center ${groupBadgeClass(issue.category_group)}`}>{groupLabel(issue.category_group)}</span>
294298
{renderBadges?.(slotCtx)}
295299
{/* The detector reason rides the title row while COLLAPSED so the
296300
key triage signal shows without expanding. When open, the full
297301
cause lives in the WHAT'S WRONG section below, so it fades out
298302
here (stays mounted — it's the flex-1 filler, so unmounting
299303
wouldn't reflow anything, but fading avoids the pop). */}
300-
{issue.reason ? (
304+
{issue.reason && !schedulingCause ? (
301305
<span
302306
aria-hidden={open || undefined}
303307
className={`min-w-0 flex-1 truncate text-xs text-theme-text-tertiary transition-opacity duration-200 ${open ? 'opacity-0' : 'opacity-100'}`}
@@ -307,6 +311,7 @@ export function IssueRow({
307311
</span>
308312
) : null}
309313
</div>
314+
{schedulingCause && <div className="break-words text-xs text-theme-text-secondary">{schedulingCause}</div>}
310315
<div className="flex min-w-0 items-center gap-1.5 text-xs text-theme-text-tertiary">
311316
<span className={KIND_CHIP_CLASS}>{issue.kind}</span>
312317
<span className="min-w-0 truncate font-medium text-theme-text-secondary">

‎packages/k8s-ui/src/components/issues/ResourceIssuesSection.test.tsx‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,3 +106,31 @@ it('keeps pod/template evidence in expanded neutral context with navigable witne
106106
expect(expanded).not.toContain('confidence')
107107
expect(expanded).not.toContain('Caused by')
108108
})
109+
110+
it('wraps a compact Pod scheduling cause separately and retains the full message in details', () => {
111+
const raw = '0/2 nodes are available: 2 Too many pods. preemption: 0/2 nodes are available: 2 No preemption victims found for incoming pod.'
112+
const pod: Issue = { ...issue, kind: 'Pod', reason: 'Unschedulable', cause: raw, message: raw }
113+
const html = renderToString(<IssueRow issue={pod} compact open onToggle={() => {}} />)
114+
expect(html).toContain('break-words text-xs text-theme-text-secondary')
115+
expect(html).toContain('both nodes have reached their Pod limit')
116+
expect(html).toContain(raw)
117+
const producerMessage = '2 node(s) insufficient pods (0/2 nodes available)'
118+
const reported = renderToString(<IssueRow issue={{ ...pod, cause: undefined, message: producerMessage }} compact open onToggle={() => {}} />)
119+
expect(reported).toContain('both nodes have reached their Pod limit')
120+
expect(reported).toContain(producerMessage)
121+
const regular = renderToString(<IssueRow issue={pod} open={false} onToggle={() => {}} />)
122+
expect(regular).not.toContain('both nodes have reached their Pod limit')
123+
})
124+
125+
it.each(['Cluster', 'Deployment'])('wraps compact scheduling causes aggregated under %s', (kind) => {
126+
const raw = '2 node(s) insufficient pods (0/2 nodes available)'
127+
const aggregated: Issue = { ...issue, kind, reason: 'Unschedulable', cause: raw, message: raw }
128+
const collapsed = renderToString(<IssueRow issue={aggregated} compact open={false} onToggle={() => {}} />)
129+
expect(collapsed).toContain('<div class="break-words text-xs text-theme-text-secondary">both nodes have reached their Pod limit</div>')
130+
expect(collapsed).not.toContain(raw)
131+
const expanded = renderToString(<IssueRow issue={aggregated} compact open onToggle={() => {}} />)
132+
expect(expanded).toContain(raw)
133+
const regular = renderToString(<IssueRow issue={aggregated} open={false} onToggle={() => {}} />)
134+
expect(regular).toContain('Unschedulable')
135+
expect(regular).not.toContain('both nodes have reached their Pod limit')
136+
})

‎packages/k8s-ui/src/components/issues/ResourceIssuesSection.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ export function ResourceIssuesSection({
88
issues,
99
onResourceClick,
1010
subjectResource,
11+
compact,
1112
}: {
13+
compact?: boolean
1214
issues: Issue[] | undefined
1315
/** When provided, related resources in a causal link become clickable. */
1416
onResourceClick?: (ref: IssueResourceRef) => void
@@ -36,6 +38,7 @@ export function ResourceIssuesSection({
3638
<IssueRow
3739
key={key}
3840
issue={issue}
41+
compact={compact}
3942
open={openId === key}
4043
onToggle={() => setOpenId((cur) => (cur === key ? null : key))}
4144
onResourceClick={onResourceClick}

‎packages/k8s-ui/src/components/issues/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,5 +24,7 @@ export {
2424
ISSUE_SEVERITY_FILL_CLASS,
2525
ISSUE_SEVERITY_RAIL_CLASS,
2626
categoryLabel,
27+
issueTitle,
28+
issueReasonTitle,
2729
groupLabel,
2830
} from './severity';

‎packages/k8s-ui/src/components/issues/issues.test.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { afterEach, describe, it, expect, vi } from 'vitest'
22
import { createElement } from 'react'
33
import { renderToString } from 'react-dom/server'
44
import { compareIssues, issueSortAnchor, subjectRef, memberRef, normalizeImagePullMessage, issueMessageParts, type Issue } from './types'
5-
import { categoryLabel, groupBadgeClass, groupLabel } from './severity'
5+
import { categoryLabel, groupBadgeClass, groupLabel, issueTitle } from './severity'
66
import { IssueRow } from './IssuesView'
77
import { issueFirstSeenTitle, issueResourceCreatedTitle, issueTiming } from './issue-timing'
88

@@ -83,6 +83,8 @@ describe('category/group label fallbacks', () => {
8383
it('returns the mapped label, else humanizes (server-added category needs no frontend deploy)', () => {
8484
expect(categoryLabel('crashloop')).toBe('Crash loop')
8585
expect(categoryLabel('some_new_future_category')).toBe('Some new future category')
86+
expect(issueTitle({ category: 'backup_failed', reason: 'CNPGWALArchivingFailing' })).toBe('WAL archiving failing')
87+
expect(issueTitle({ category: 'backup_failed', reason: 'BackupFailed' })).toBe('Backup failed')
8688
})
8789
it('humanizes an unmapped group', () => {
8890
expect(groupLabel('runtime')).toBe('Runtime')

0 commit comments

Comments
 (0)