Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
5b3ab9a
Detail pages: name and status on the title line, tabs that fit at lap…
nadaverell Oct 11, 2026
586f03c
Logs: wrap the toolbar, one status line, Export and Clear only with l…
nadaverell Oct 11, 2026
20c77b4
Pod drawer: containers that haven't started read Not started, and a P…
nadaverell Oct 11, 2026
b0bdfec
Issue rows: say why a Pod can't be scheduled in plain words, and titl…
nadaverell Oct 11, 2026
671be83
Namespace switch: wait longer, and accept a switch the server applied…
nadaverell Oct 11, 2026
325607f
Timeline: tag Pod rows by the state the change produced
nadaverell Oct 11, 2026
2ecf2b6
Sidebar: a filter keeps a category's matching views
nadaverell Oct 11, 2026
57e14b1
Escape closes a row menu without closing the drawer; YAML Edit uses t…
nadaverell Oct 11, 2026
e8fbb5e
Ask a host for its status badge only once the resource has loaded
nadaverell Oct 11, 2026
695922c
Accept a timed-out namespace switch only for a view-filter pick
nadaverell Oct 11, 2026
e816a5d
Drop the scheduler's dynamic-resource tail without a backtracking regex
nadaverell Oct 11, 2026
6ad5ecd
A Pending Pod running an init container isn't 'not running'
nadaverell Oct 11, 2026
ba5c91a
Timeline: lost Pod readiness stays degraded until it returns
nadaverell Oct 11, 2026
8ec2a28
Split scheduler messages into clauses in one pass
nadaverell Oct 11, 2026
4ea55e8
Drawer header keeps the name on its own full-width line
nadaverell Oct 11, 2026
cd6007f
Timeline: a clean restart doesn't end a readiness incident
nadaverell Oct 12, 2026
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 internal/k8s/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -916,7 +916,7 @@ func recordToTimelineStore(clusterContext, kind, namespace, name, uid, op string
}
labels := entry.Labels
createdAt := entry.CreatedAt
healthState := classifyTimelineHealth(kind, obj, time.Now())
healthState := classifyTimelineChangeHealth(kind, oldObj, obj, time.Now())

// Feed the tombstone on every add/update/delete. While the object is live
// this mirrors its enrichment; once it is gone (delete, or a late K8s event
Expand Down
69 changes: 69 additions & 0 deletions internal/k8s/timeline_health.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,72 @@ func levelToTimeline(l health.Level) timeline.HealthState {
return timeline.HealthUnknown
}
}

// classifyTimelineChangeHealth labels one recorded change. The canonical
// classifier judges standing state, so it holds back on a fresh failure (a
// readiness grace, a restart threshold); a timeline row instead labels the
// moment it records, and a Pod whose container just exited with an error, or
// that just lost readiness, is not healthy at that moment.
func classifyTimelineChangeHealth(kind string, oldObj, newObj any, now time.Time) timeline.HealthState {
pod, ok := newObj.(*corev1.Pod)
if kind != "Pod" || !ok {
return classifyTimelineHealth(kind, newObj, now)
}
level := health.PodDisplayLevel(pod, now)
if pod.Status.Phase != corev1.PodRunning || pod.DeletionTimestamp != nil {
return levelToTimeline(level)
}
completing := false
for _, cs := range pod.Status.ContainerStatuses {
if t := cs.State.Terminated; t != nil {
if t.ExitCode != 0 {
level = health.WorseOf(level, health.LevelUnhealthy)
} else if pod.Spec.RestartPolicy != corev1.RestartPolicyAlways {
// Under restartPolicy Always a clean exit is a restart, not completion.
completing = true
}
}
}
// A container finishing cleanly also drops readiness; that is completion.
// Lost readiness stays degraded on every later row until it returns, rather
// than reading healthy again inside the standing-state grace.
if !completing && !timelinePodReady(pod) {
if old, ok := oldObj.(*corev1.Pod); (ok && old != pod && timelinePodReady(old)) || timelinePodLostReadiness(pod) {
level = health.WorseOf(level, health.LevelDegraded)
}
}
return levelToTimeline(level)
}

// timelinePodLostReadiness reports readiness that went false after a container
// had started, in its current run or the one before a restart: lost, not
// still to come at startup.
func timelinePodLostReadiness(p *corev1.Pod) bool {
var since time.Time
for _, c := range p.Status.Conditions {
if c.Type == corev1.PodReady && c.Status == corev1.ConditionFalse {
since = c.LastTransitionTime.Time
}
}
if since.IsZero() {
return false
}
for _, cs := range p.Status.ContainerStatuses {
if r := cs.State.Running; r != nil && !r.StartedAt.IsZero() && r.StartedAt.Time.Before(since) {
return true
}
if t := cs.LastTerminationState.Terminated; t != nil && !t.StartedAt.IsZero() && t.StartedAt.Time.Before(since) {
return true
}
}
return false
}

func timelinePodReady(p *corev1.Pod) bool {
for _, c := range p.Status.Conditions {
if c.Type == corev1.PodReady {
return c.Status == corev1.ConditionTrue
}
}
return false
}
57 changes: 57 additions & 0 deletions internal/k8s/timeline_health_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,3 +149,60 @@ func TestClassifyTimelineHealthWorkloads(t *testing.T) {
})
}
}

func TestClassifyTimelineChangeHealthPod(t *testing.T) {
now := time.Now()
readyAt := func(v corev1.ConditionStatus, at time.Time) []corev1.PodCondition {
return []corev1.PodCondition{{Type: corev1.PodReady, Status: v, LastTransitionTime: metav1.NewTime(at)}}
}
ready := func(v corev1.ConditionStatus) []corev1.PodCondition { return readyAt(v, now) }
runningAt := func(at time.Time) corev1.ContainerState {
return corev1.ContainerState{Running: &corev1.ContainerStateRunning{StartedAt: metav1.NewTime(at)}}
}
running := runningAt(now.Add(-time.Hour))
pod := func(conds []corev1.PodCondition, state corev1.ContainerState, restarts int32) *corev1.Pod {
return &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{CreationTimestamp: metav1.NewTime(now.Add(-time.Hour))},
Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "postgres", ReadinessProbe: &corev1.Probe{}}}},
Status: corev1.PodStatus{
Phase: corev1.PodRunning, Conditions: conds,
ContainerStatuses: []corev1.ContainerStatus{{Name: "postgres", State: state, RestartCount: restarts}},
},
}
}
errored := corev1.ContainerState{Terminated: &corev1.ContainerStateTerminated{Reason: "Error", ExitCode: 1}}
completed := corev1.ContainerState{Terminated: &corev1.ContainerStateTerminated{Reason: "Completed", ExitCode: 0}}
policy := func(p *corev1.Pod, rp corev1.RestartPolicy) *corev1.Pod { p.Spec.RestartPolicy = rp; return p }
restarted := func(p *corev1.Pod) *corev1.Pod {
p.Status.ContainerStatuses[0].LastTerminationState = corev1.ContainerState{Terminated: &corev1.ContainerStateTerminated{ExitCode: 0, StartedAt: metav1.NewTime(now.Add(-time.Hour))}}
return p
}
cases := []struct {
name string
old, new *corev1.Pod
want timeline.HealthState
}{
{"container exited with an error", pod(ready(corev1.ConditionTrue), running, 0), pod(ready(corev1.ConditionFalse), errored, 1), timeline.HealthUnhealthy},
{"lost readiness while running", pod(ready(corev1.ConditionTrue), running, 0), pod(ready(corev1.ConditionFalse), running, 0), timeline.HealthDegraded},
{"clean completion is not a failure", pod(ready(corev1.ConditionTrue), running, 0), pod(ready(corev1.ConditionFalse), completed, 0), timeline.HealthHealthy},
{"regained readiness", pod(ready(corev1.ConditionFalse), running, 0), pod(ready(corev1.ConditionTrue), running, 0), timeline.HealthHealthy},
{"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},
{"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},
{"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},
{"added after readiness was lost is degraded", nil, pod(readyAt(corev1.ConditionFalse, now.Add(-2*time.Minute)), running, 0), timeline.HealthDegraded},
{"a clean exit completes a Job Pod", pod(ready(corev1.ConditionTrue), running, 0), policy(pod(ready(corev1.ConditionFalse), completed, 0), corev1.RestartPolicyOnFailure), timeline.HealthHealthy},
{"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},
{"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},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var old any
if tc.old != nil {
old = tc.old
}
if got := classifyTimelineChangeHealth("Pod", old, tc.new, now); got != tc.want {
t.Errorf("got %q, want %q", got, tc.want)
}
})
}
}
12 changes: 10 additions & 2 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -3488,7 +3488,12 @@ func (s *Server) handlePodMetricsHistory(w http.ResponseWriter, r *http.Request)
s.writeJSON(w, history)
}

func podMetricsHistoryResponse(ctx context.Context, history *k8s.PodMetricsHistory, namespace, name string, health k8s.MetricsCollectionHealth, includeAPIServiceConditionMessage bool) *k8s.PodMetricsHistory {
type podMetricsHistoryWithAvailability struct {
*k8s.PodMetricsHistory
MetricsAPIReachable bool `json:"metricsAPIReachable"`
}

func podMetricsHistoryResponse(ctx context.Context, history *k8s.PodMetricsHistory, namespace, name string, health k8s.MetricsCollectionHealth, includeAPIServiceConditionMessage bool) *podMetricsHistoryWithAvailability {
if history == nil {
history = &k8s.PodMetricsHistory{
Namespace: namespace,
Expand All @@ -3499,7 +3504,10 @@ func podMetricsHistoryResponse(ctx context.Context, history *k8s.PodMetricsHisto
if health.PodMetrics.ConsecutiveErrors > 0 {
history.CollectionError, history.RawCollectionError, history.MetricsUnavailableDiagnosis, history.MetricsUnavailable = metricsHistoryCollectionError(ctx, "Pod", health.PodMetrics.LastError, includeAPIServiceConditionMessage)
}
return history
return &podMetricsHistoryWithAvailability{
PodMetricsHistory: history,
MetricsAPIReachable: health.PodMetrics.LastSuccess != "" && health.PodMetrics.ConsecutiveErrors == 0,
}
}

// handleNodeMetricsHistory returns historical metrics for a specific node
Expand Down
14 changes: 14 additions & 0 deletions internal/server/server_smoke_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2055,3 +2055,17 @@ func TestSmokeChangesExactGroup(t *testing.T) {
t.Fatalf("Volcano Job history = %v, want only its own versioned rows", got)
}
}

func TestPodMetricsHistoryAPIReachability(t *testing.T) {
for _, tc := range []struct {
lastSuccess string
failures int
reachable bool
}{{"", 0, false}, {"2026-10-06T00:00:00Z", 0, true}, {"2026-10-06T00:00:00Z", 1, false}} {
health := k8s.MetricsCollectionHealth{PodMetrics: k8s.MetricsSourceHealth{LastSuccess: tc.lastSuccess, ConsecutiveErrors: tc.failures, LastError: "transport failed"}}
got := podMetricsHistoryResponse(context.Background(), nil, "default", "nginx", health, false)
if got.MetricsAPIReachable != tc.reachable {
t.Fatalf("%+v: %+v", tc, got)
}
}
}
11 changes: 8 additions & 3 deletions packages/k8s-ui/src/components/issues/IssuesView.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { summarizeSchedulerMessage } from '../resources/resource-utils'
import { useMemo, useState, type ComponentType, type ReactNode } from 'react';
import { AlertOctagon, AlertTriangle, ArrowRight, CircleCheck, Clock, ExternalLink, Layers, Terminal, Workflow } from 'lucide-react';
import { CardBody, CardSection, ClusterName, EmptyState, KIND_CHIP_CLASS, TerminalBlock } from '../ui';
Expand All @@ -13,7 +14,7 @@ import {
ISSUE_SEVERITY_RAIL_CLASS,
ISSUE_SEVERITY_SOLID_CLASS,
ISSUE_SEVERITY_TEXT_CLASS,
categoryLabel,
issueTitle,
groupBadgeClass,
groupLabel,
} from './severity';
Expand Down Expand Up @@ -168,6 +169,7 @@ export interface IssueRowProps {
resourceHref?: (ref: IssueResourceRef) => string;
onResourceClick?: (ref: IssueResourceRef) => void;
as?: 'li' | 'div';
compact?: boolean;
className?: string;
dimmed?: boolean;
/** Suppress the "Subject" deep-link in the expanded body — set by hosts that
Expand Down Expand Up @@ -195,6 +197,7 @@ export function IssueRow({
resourceHref,
onResourceClick,
as = 'li',
compact = false,
className,
dimmed,
hideSubject,
Expand All @@ -208,6 +211,7 @@ export function IssueRow({
const cluster = clusterLabel?.(issue);
const affected = affectedSummary(issue.affected);
const { headline } = issueMessageParts(issue);
const schedulingCause = compact && issue.reason === 'Unschedulable' ? summarizeSchedulerMessage(issue.cause || issue.message || headline || '', { plain: true }) : undefined;
const { panelId, buttonProps } = useDisclosure(open);
const Container = as;
const severity = normalizeIssueSeverity(issue.severity);
Expand Down Expand Up @@ -289,15 +293,15 @@ export function IssueRow({

<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex min-w-0 items-baseline gap-2">
<span className="min-w-0 truncate text-sm font-medium text-theme-text-primary">{categoryLabel(issue.category)}</span>
<span className="min-w-0 truncate text-sm font-medium text-theme-text-primary">{issueTitle(issue)}</span>
<span className={`shrink-0 self-center ${groupBadgeClass(issue.category_group)}`}>{groupLabel(issue.category_group)}</span>
{renderBadges?.(slotCtx)}
{/* The detector reason rides the title row while COLLAPSED so the
key triage signal shows without expanding. When open, the full
cause lives in the WHAT'S WRONG section below, so it fades out
here (stays mounted — it's the flex-1 filler, so unmounting
wouldn't reflow anything, but fading avoids the pop). */}
{issue.reason ? (
{issue.reason && !schedulingCause ? (
<span
aria-hidden={open || undefined}
className={`min-w-0 flex-1 truncate text-xs text-theme-text-tertiary transition-opacity duration-200 ${open ? 'opacity-0' : 'opacity-100'}`}
Expand All @@ -307,6 +311,7 @@ export function IssueRow({
</span>
) : null}
</div>
{schedulingCause && <div className="break-words text-xs text-theme-text-secondary">{schedulingCause}</div>}
<div className="flex min-w-0 items-center gap-1.5 text-xs text-theme-text-tertiary">
<span className={KIND_CHIP_CLASS}>{issue.kind}</span>
<span className="min-w-0 truncate font-medium text-theme-text-secondary">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,3 +106,31 @@ it('keeps pod/template evidence in expanded neutral context with navigable witne
expect(expanded).not.toContain('confidence')
expect(expanded).not.toContain('Caused by')
})

it('wraps a compact Pod scheduling cause separately and retains the full message in details', () => {
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.'
const pod: Issue = { ...issue, kind: 'Pod', reason: 'Unschedulable', cause: raw, message: raw }
const html = renderToString(<IssueRow issue={pod} compact open onToggle={() => {}} />)
expect(html).toContain('break-words text-xs text-theme-text-secondary')
expect(html).toContain('both nodes have reached their Pod limit')
expect(html).toContain(raw)
const producerMessage = '2 node(s) insufficient pods (0/2 nodes available)'
const reported = renderToString(<IssueRow issue={{ ...pod, cause: undefined, message: producerMessage }} compact open onToggle={() => {}} />)
expect(reported).toContain('both nodes have reached their Pod limit')
expect(reported).toContain(producerMessage)
const regular = renderToString(<IssueRow issue={pod} open={false} onToggle={() => {}} />)
expect(regular).not.toContain('both nodes have reached their Pod limit')
})

it.each(['Cluster', 'Deployment'])('wraps compact scheduling causes aggregated under %s', (kind) => {
const raw = '2 node(s) insufficient pods (0/2 nodes available)'
const aggregated: Issue = { ...issue, kind, reason: 'Unschedulable', cause: raw, message: raw }
const collapsed = renderToString(<IssueRow issue={aggregated} compact open={false} onToggle={() => {}} />)
expect(collapsed).toContain('<div class="break-words text-xs text-theme-text-secondary">both nodes have reached their Pod limit</div>')
expect(collapsed).not.toContain(raw)
const expanded = renderToString(<IssueRow issue={aggregated} compact open onToggle={() => {}} />)
expect(expanded).toContain(raw)
const regular = renderToString(<IssueRow issue={aggregated} open={false} onToggle={() => {}} />)
expect(regular).toContain('Unschedulable')
expect(regular).not.toContain('both nodes have reached their Pod limit')
})
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ export function ResourceIssuesSection({
issues,
onResourceClick,
subjectResource,
compact,
}: {
compact?: boolean
issues: Issue[] | undefined
/** When provided, related resources in a causal link become clickable. */
onResourceClick?: (ref: IssueResourceRef) => void
Expand Down Expand Up @@ -36,6 +38,7 @@ export function ResourceIssuesSection({
<IssueRow
key={key}
issue={issue}
compact={compact}
open={openId === key}
onToggle={() => setOpenId((cur) => (cur === key ? null : key))}
onResourceClick={onResourceClick}
Expand Down
2 changes: 2 additions & 0 deletions packages/k8s-ui/src/components/issues/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,7 @@ export {
ISSUE_SEVERITY_FILL_CLASS,
ISSUE_SEVERITY_RAIL_CLASS,
categoryLabel,
issueTitle,
issueReasonTitle,
groupLabel,
} from './severity';
4 changes: 3 additions & 1 deletion packages/k8s-ui/src/components/issues/issues.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { afterEach, describe, it, expect, vi } from 'vitest'
import { createElement } from 'react'
import { renderToString } from 'react-dom/server'
import { compareIssues, issueSortAnchor, subjectRef, memberRef, normalizeImagePullMessage, issueMessageParts, type Issue } from './types'
import { categoryLabel, groupBadgeClass, groupLabel } from './severity'
import { categoryLabel, groupBadgeClass, groupLabel, issueTitle } from './severity'
import { IssueRow } from './IssuesView'
import { issueFirstSeenTitle, issueResourceCreatedTitle, issueTiming } from './issue-timing'

Expand Down Expand Up @@ -83,6 +83,8 @@ describe('category/group label fallbacks', () => {
it('returns the mapped label, else humanizes (server-added category needs no frontend deploy)', () => {
expect(categoryLabel('crashloop')).toBe('Crash loop')
expect(categoryLabel('some_new_future_category')).toBe('Some new future category')
expect(issueTitle({ category: 'backup_failed', reason: 'CNPGWALArchivingFailing' })).toBe('WAL archiving failing')
expect(issueTitle({ category: 'backup_failed', reason: 'BackupFailed' })).toBe('Backup failed')
})
it('humanizes an unmapped group', () => {
expect(groupLabel('runtime')).toBe('Runtime')
Expand Down
21 changes: 21 additions & 0 deletions packages/k8s-ui/src/components/issues/severity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,27 @@ export function categoryLabel(category: string): string {
return CATEGORY_LABEL[category] ?? humanize(category);
}

// Reasons filed under a category whose label names a different operation:
// CNPG archiving and an unanswered scheduled run sit under backup_failed.
const REASON_TITLE: Record<string, string> = {
CNPGWALArchivingFailing: "WAL archiving failing",
CNPGLastBackupFailed: "Latest backup failed",
CNPGScheduleDestinationMissing: "Backup schedule has no destination",
CNPGInstanceReadinessMismatch: "Instance Pods contradict Cluster readiness",
CNPGPrimaryLabelMismatch: "Primary Pod label contradicts Cluster status",
CNPGScheduledRunNoBackup: "No successful backup since a scheduled run",
};

/** The title a reason has wherever it is shown, when its category's label would name the wrong operation. */
export function issueReasonTitle(reason: string | undefined): string | undefined {
return reason ? REASON_TITLE[reason] : undefined;
}

/** An issue row's title: its category, unless the reason names it better. */
export function issueTitle(issue: { category: string; reason?: string }): string {
return issueReasonTitle(issue.reason) || categoryLabel(issue.category);
}

export function groupLabel(group: string): string {
return GROUP_LABEL[group] ?? humanize(group);
}
Loading
Loading