@skyhook-io/k8s-ui 1.7.7 → 1.7.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/package.json +6 -1
  2. package/src/components/charts/PrometheusChartsView.tsx +233 -0
  3. package/src/components/charts/index.ts +13 -0
  4. package/src/components/checks/ChecksView.tsx +5 -1
  5. package/src/components/gitops/GitOpsTableView.tsx +1 -2
  6. package/src/components/gitops/insights/GitOpsInsightViews.tsx +3 -3
  7. package/src/components/issues/IssuesView.tsx +352 -0
  8. package/src/components/issues/index.ts +24 -0
  9. package/src/components/issues/issues.test.ts +100 -0
  10. package/src/components/issues/severity.ts +125 -0
  11. package/src/components/issues/types.ts +194 -0
  12. package/src/components/resources/ResourcesView.tsx +2 -19
  13. package/src/components/resources/renderers/CAPIKubeadmControlPlaneRenderer.tsx +1 -1
  14. package/src/components/resources/renderers/CAPIMachineDeploymentRenderer.tsx +1 -1
  15. package/src/components/resources/renderers/CAPIMachineSetRenderer.tsx +1 -1
  16. package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +1 -1
  17. package/src/components/resources/renderers/CompositionRenderer.tsx +3 -3
  18. package/src/components/resources/renderers/CrossplanePackageRenderer.tsx +5 -5
  19. package/src/components/resources/renderers/CrossplaneProviderConfigRenderer.tsx +1 -1
  20. package/src/components/resources/renderers/KnativeSourceRenderer.tsx +1 -1
  21. package/src/components/resources/renderers/PodRenderer.test.tsx +53 -0
  22. package/src/components/resources/renderers/PodRenderer.tsx +7 -1
  23. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +1 -1
  24. package/src/components/resources/renderers/WorkloadRenderer.test.tsx +41 -0
  25. package/src/components/resources/renderers/WorkloadRenderer.tsx +49 -7
  26. package/src/components/resources/renderers/XRDRenderer.tsx +2 -2
  27. package/src/components/shared/DetailShell.tsx +107 -0
  28. package/src/components/shared/ManagedByChip.tsx +149 -16
  29. package/src/components/shared/ResourceRendererDispatch.test.tsx +45 -0
  30. package/src/components/shared/ResourceRendererDispatch.tsx +13 -1
  31. package/src/components/shared/index.ts +2 -1
  32. package/src/components/timeline/TimelineSwimlanes.tsx +1320 -0
  33. package/src/components/timeline/index.ts +1 -0
  34. package/src/components/topology/K8sResourceNode.tsx +60 -60
  35. package/src/components/topology/TopologyFilterSidebar.tsx +25 -3
  36. package/src/components/topology/TopologyGraph.tsx +168 -52
  37. package/src/components/topology/TopologySearch.tsx +17 -8
  38. package/src/components/topology/topology-search-match.test.ts +1 -1
  39. package/src/components/topology/topology.css +18 -0
  40. package/src/components/ui/Tooltip.tsx +37 -2
  41. package/src/components/workload/WorkloadView.tsx +118 -112
  42. package/src/index.ts +5 -0
  43. package/src/theme/components.css +16 -0
  44. package/src/types/core.ts +3 -2
  45. package/src/utils/env-from.ts +3 -0
  46. package/src/utils/index.ts +1 -0
  47. package/src/utils/replica-scalers.ts +9 -0
  48. package/src/components/resources/resources-search-sidebar-hint.test.ts +0 -85
@@ -0,0 +1,24 @@
1
+ // Explicit exports (not `export *`) so the generic identity helpers stay
2
+ // module-internal and don't collide at the top-level barrel with the Checks
3
+ // queue's identically-named helpers when both land. Issue-prefixed public
4
+ // names are safe to surface.
5
+ export { IssuesView } from './IssuesView';
6
+ export type { IssuesViewProps } from './IssuesView';
7
+ export {
8
+ ISSUE_SEVERITIES,
9
+ ISSUE_SEVERITY_RANK,
10
+ isIssueSeverity,
11
+ subjectRef,
12
+ memberRef,
13
+ } from './types';
14
+ export type { Issue, IssueSeverity, IssueAffected, IssueResourceRef } from './types';
15
+ export {
16
+ ISSUE_SEVERITY_LABEL,
17
+ ISSUE_SEVERITY_BADGE_CLASS,
18
+ ISSUE_SEVERITY_FILL_CLASS,
19
+ ISSUE_SEVERITY_TEXT_CLASS,
20
+ ISSUE_SEVERITY_RAIL_CLASS,
21
+ groupBadgeClass,
22
+ categoryLabel,
23
+ groupLabel,
24
+ } from './severity';
@@ -0,0 +1,100 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { compareIssues, subjectRef, memberRef, normalizeImagePullMessage, issueMessageParts, type Issue } from './types'
3
+ import { categoryLabel, groupLabel, groupBadgeClass } from './severity'
4
+
5
+ const base: Issue = {
6
+ id: 'id-0',
7
+ severity: 'warning',
8
+ source: 'problem',
9
+ category: 'crashloop',
10
+ category_group: 'runtime',
11
+ grouping_scope: 'workload',
12
+ kind: 'Deployment',
13
+ name: 'app',
14
+ reason: 'CrashLoopBackOff',
15
+ }
16
+ const mk = (o: Partial<Issue>): Issue => ({ ...base, ...o })
17
+
18
+ describe('compareIssues', () => {
19
+ it('orders critical before warning regardless of onset', () => {
20
+ const warn = mk({ id: 'w', severity: 'warning', first_seen: '2026-05-01T00:00:00Z' }) // newer
21
+ const crit = mk({ id: 'c', severity: 'critical', first_seen: '2026-01-01T00:00:00Z' }) // older
22
+ expect([warn, crit].sort(compareIssues).map((i) => i.id)).toEqual(['c', 'w'])
23
+ })
24
+
25
+ it('breaks same-severity ties by first_seen DESC (newest onset first)', () => {
26
+ const older = mk({ id: 'o', first_seen: '2026-01-01T00:00:00Z' })
27
+ const newer = mk({ id: 'n', first_seen: '2026-05-01T00:00:00Z' })
28
+ expect([older, newer].sort(compareIssues).map((i) => i.id)).toEqual(['n', 'o'])
29
+ })
30
+
31
+ it('does NOT reshuffle same-severity rows when only last_seen changes (anti-churn)', () => {
32
+ // Two same-severity rows, same onset — order is the deterministic name tiebreak.
33
+ const a = mk({ id: 'id-a', name: 'a', first_seen: '2026-01-01T00:00:00Z', last_seen: '2026-05-01T00:00:00Z' })
34
+ const b = mk({ id: 'id-b', name: 'b', first_seen: '2026-01-01T00:00:00Z', last_seen: '2026-05-30T00:00:00Z' })
35
+ const before = [a, b].sort(compareIssues).map((i) => i.id)
36
+ expect(before).toEqual(['id-a', 'id-b'])
37
+ // A refetch bumps a's last_seen to "now". Sorting on last_seen would flip the
38
+ // order; keying on first_seen + identity must NOT — this is the whole point
39
+ // of the onset-based sort.
40
+ const aRefetched = mk({ ...a, last_seen: '2026-06-01T00:00:00Z' })
41
+ const after = [aRefetched, b].sort(compareIssues).map((i) => i.id)
42
+ expect(after).toEqual(before)
43
+ })
44
+ })
45
+
46
+ describe('category/group label fallbacks', () => {
47
+ it('returns the mapped label, else humanizes (server-added category needs no frontend deploy)', () => {
48
+ expect(categoryLabel('crashloop')).toBe('Crash loop')
49
+ expect(categoryLabel('some_new_future_category')).toBe('Some new future category')
50
+ })
51
+ it('humanizes an unmapped group', () => {
52
+ expect(groupLabel('runtime')).toBe('Runtime')
53
+ expect(groupLabel('some_future_group')).toBe('Some future group')
54
+ })
55
+ it('groupBadgeClass falls back to a non-empty neutral class for an unknown group', () => {
56
+ expect(groupBadgeClass('totally_unknown_group')).toBeTruthy()
57
+ })
58
+ })
59
+
60
+ describe('subjectRef / memberRef', () => {
61
+ it('subjectRef defaults empty group/namespace and threads cluster_id', () => {
62
+ const issue = mk({ cluster_id: 'cl_1', kind: 'Deployment', name: 'web' }) // no group/namespace
63
+ expect(subjectRef(issue)).toEqual({ cluster_id: 'cl_1', group: '', kind: 'Deployment', namespace: '', name: 'web' })
64
+ })
65
+ it('memberRef threads the issue cluster_id onto a member', () => {
66
+ const issue = mk({ cluster_id: 'cl_2' })
67
+ const member = { group: 'apps', kind: 'Pod', namespace: 'ns', name: 'p1' }
68
+ expect(memberRef(issue, member)).toEqual({ ...member, cluster_id: 'cl_2' })
69
+ })
70
+ })
71
+
72
+ describe('image-pull message normalization', () => {
73
+ const notFound =
74
+ 'Back-off pulling image "reg.io/team/api:v2": ErrImagePull: rpc error: code = NotFound desc = failed to pull and unpack image "reg.io/team/api:v2": failed to resolve reference "reg.io/team/api:v2": "reg.io/team/api:v2": not found'
75
+
76
+ it('extracts cause + single image ref from the verbose CRI string', () => {
77
+ expect(normalizeImagePullMessage(notFound)).toBe('Image not found: reg.io/team/api:v2')
78
+ })
79
+ it('classifies the common failure modes', () => {
80
+ expect(normalizeImagePullMessage('pull access denied for image "x:1", repository does not exist or may require authorization')).toBe('Not authorized to pull image: x:1')
81
+ expect(normalizeImagePullMessage('failed to pull image "x:1": dial tcp: lookup reg.io: no such host')).toBe('Registry unreachable: x:1')
82
+ expect(normalizeImagePullMessage('toomanyrequests: rate limit exceeded for image "x:1"')).toBe('Registry rate-limited: x:1')
83
+ })
84
+ it('returns null for shapes it does not recognize (caller keeps raw)', () => {
85
+ expect(normalizeImagePullMessage('some novel kubelet error')).toBeNull()
86
+ expect(normalizeImagePullMessage('')).toBeNull()
87
+ })
88
+
89
+ it('issueMessageParts normalizes image-pull headline and keeps raw as detail', () => {
90
+ const parts = issueMessageParts(mk({ category: 'image_pull_failed', reason: 'ImagePullBackOff', message: notFound }))
91
+ expect(parts.headline).toBe('Image not found: reg.io/team/api:v2')
92
+ expect(parts.detail).toBe(notFound)
93
+ })
94
+ it('does NOT mislabel a non-image "not found" message (gating)', () => {
95
+ // missing_config_ref carries 'secret "x" not found' — must stay verbatim, no detail split.
96
+ const parts = issueMessageParts(mk({ category: 'missing_config_ref', reason: 'Missing Secret', message: 'secret "project-infra" not found' }))
97
+ expect(parts.headline).toBe('secret "project-infra" not found')
98
+ expect(parts.detail).toBe('')
99
+ })
100
+ })
@@ -0,0 +1,125 @@
1
+ import type { IssueSeverity } from './types';
2
+
3
+ // Visual language for the 2-tier Issues severity, deliberately reusing the
4
+ // SAME class strings as the Checks queue (components/checks/severity.ts):
5
+ // critical = red (= Checks `critical`), warning = amber (= Checks `medium`).
6
+ // Issues and Checks are different severity axes, but the queues must read as
7
+ // one product — sharing the exact hues makes the rails/pills pixel-identical.
8
+ //
9
+ // Class strings are literal so each consuming app's Tailwind @source scan emits
10
+ // them.
11
+
12
+ export const ISSUE_SEVERITY_LABEL: Record<IssueSeverity, string> = {
13
+ critical: 'Critical',
14
+ warning: 'Warning',
15
+ };
16
+
17
+ // Pill badge — the loud, explicit severity signal on a row.
18
+ export const ISSUE_SEVERITY_BADGE_CLASS: Record<IssueSeverity, string> = {
19
+ critical: 'bg-red-50 text-red-700 ring-1 ring-red-200 dark:bg-red-950/50 dark:text-red-300 dark:ring-red-900',
20
+ warning: 'bg-amber-50 text-amber-700 ring-1 ring-amber-200 dark:bg-amber-950/50 dark:text-amber-300 dark:ring-amber-900',
21
+ };
22
+
23
+ // Solid fill — dots + the proportional distribution bar segments.
24
+ export const ISSUE_SEVERITY_FILL_CLASS: Record<IssueSeverity, string> = {
25
+ critical: 'bg-red-500',
26
+ warning: 'bg-amber-500',
27
+ };
28
+
29
+ export const ISSUE_SEVERITY_TEXT_CLASS: Record<IssueSeverity, string> = {
30
+ critical: 'text-red-600 dark:text-red-400',
31
+ warning: 'text-amber-600 dark:text-amber-400',
32
+ };
33
+
34
+ // Left accent rail on a queue row — the scan-down severity cue.
35
+ export const ISSUE_SEVERITY_RAIL_CLASS: Record<IssueSeverity, string> = {
36
+ critical: 'border-l-red-500 hover:bg-red-50/40 dark:hover:bg-red-950/20',
37
+ warning: 'border-l-amber-500 hover:bg-amber-50/30 dark:hover:bg-amber-950/15',
38
+ };
39
+
40
+ // Category-group accent — the quiet classification tag (severity is the loud
41
+ // one). One hue per group; unknown/unmapped falls back to a neutral theme tag.
42
+ const GROUP_BADGE_CLASS: Record<string, string> = {
43
+ scheduling: 'bg-violet-50 text-violet-700 ring-1 ring-violet-200 dark:bg-violet-950/40 dark:text-violet-300 dark:ring-violet-900',
44
+ startup: 'bg-sky-50 text-sky-700 ring-1 ring-sky-200 dark:bg-sky-950/40 dark:text-sky-300 dark:ring-sky-900',
45
+ runtime: 'bg-rose-50 text-rose-700 ring-1 ring-rose-200 dark:bg-rose-950/40 dark:text-rose-300 dark:ring-rose-900',
46
+ configuration: 'bg-teal-50 text-teal-700 ring-1 ring-teal-200 dark:bg-teal-950/40 dark:text-teal-300 dark:ring-teal-900',
47
+ networking: 'bg-indigo-50 text-indigo-700 ring-1 ring-indigo-200 dark:bg-indigo-950/40 dark:text-indigo-300 dark:ring-indigo-900',
48
+ storage: 'bg-cyan-50 text-cyan-700 ring-1 ring-cyan-200 dark:bg-cyan-950/40 dark:text-cyan-300 dark:ring-cyan-900',
49
+ scaling: 'bg-fuchsia-50 text-fuchsia-700 ring-1 ring-fuchsia-200 dark:bg-fuchsia-950/40 dark:text-fuchsia-300 dark:ring-fuchsia-900',
50
+ security: 'bg-amber-50 text-amber-700 ring-1 ring-amber-200 dark:bg-amber-950/40 dark:text-amber-300 dark:ring-amber-900',
51
+ control_plane: 'bg-slate-100 text-slate-600 ring-1 ring-slate-200 dark:bg-slate-800/60 dark:text-slate-300 dark:ring-slate-700',
52
+ };
53
+
54
+ export function groupBadgeClass(group: string): string {
55
+ return GROUP_BADGE_CLASS[group] ?? 'bg-theme-elevated text-theme-text-secondary ring-1 ring-theme-border';
56
+ }
57
+
58
+ // Display labels. The server emits raw snake_case category/group enums (so a
59
+ // new category needs no frontend deploy to APPEAR); the UI humanizes for
60
+ // display, falling back to title-cased snake_case for anything unmapped.
61
+ const CATEGORY_LABEL: Record<string, string> = {
62
+ unschedulable: 'Unschedulable',
63
+ quota_exceeded: 'Quota exceeded',
64
+ admission_webhook_blocking: 'Admission blocked',
65
+ image_pull_failed: 'Image pull failed',
66
+ container_waiting: 'Container waiting',
67
+ init_container_failed: 'Init container failed',
68
+ crashloop: 'Crash loop',
69
+ oom_killed: 'OOM killed',
70
+ liveness_probe_failed: 'Liveness probe failing',
71
+ readiness_failed: 'Readiness failing',
72
+ workload_degraded: 'Workload degraded',
73
+ high_restart: 'High restart count',
74
+ missing_config_ref: 'Missing reference',
75
+ pdb_blocks_evictions: 'PDB blocks evictions',
76
+ service_no_endpoints: 'No endpoints',
77
+ ingress_backend_missing: 'Ingress backend missing',
78
+ dns_failure: 'DNS failure',
79
+ network_policy_block: 'Network policy block',
80
+ pvc_pending: 'PVC pending',
81
+ pvc_lost: 'PVC lost',
82
+ volume_mount_failed: 'Volume mount failed',
83
+ volume_access_mode_conflict: 'Volume access conflict',
84
+ job_failed: 'Job failed',
85
+ cronjob_failed: 'CronJob failed',
86
+ rollout_stalled: 'Rollout stalled',
87
+ hpa_limited_or_failed: 'HPA limited',
88
+ rbac_forbidden: 'RBAC forbidden',
89
+ certificate_not_ready: 'Certificate not ready',
90
+ pod_security_violation: 'Pod Security violation',
91
+ node_not_ready: 'Node not ready',
92
+ operator_condition_failed: 'Controller condition',
93
+ gitops_sync_failed: 'GitOps sync failed',
94
+ webhook_backend_down: 'Webhook backend down',
95
+ control_plane_not_ready: 'Control plane not ready',
96
+ machine_not_ready: 'Machine not ready',
97
+ unknown: 'Unknown',
98
+ };
99
+
100
+ const GROUP_LABEL: Record<string, string> = {
101
+ scheduling: 'Scheduling',
102
+ startup: 'Startup',
103
+ runtime: 'Runtime',
104
+ configuration: 'Configuration',
105
+ networking: 'Networking',
106
+ storage: 'Storage',
107
+ scaling: 'Scaling',
108
+ security: 'Security',
109
+ control_plane: 'Control plane',
110
+ unknown: 'Unknown',
111
+ };
112
+
113
+ function humanize(raw: string): string {
114
+ if (!raw) return '';
115
+ const spaced = raw.replace(/_/g, ' ');
116
+ return spaced.charAt(0).toUpperCase() + spaced.slice(1);
117
+ }
118
+
119
+ export function categoryLabel(category: string): string {
120
+ return CATEGORY_LABEL[category] ?? humanize(category);
121
+ }
122
+
123
+ export function groupLabel(group: string): string {
124
+ return GROUP_LABEL[group] ?? humanize(group);
125
+ }
@@ -0,0 +1,194 @@
1
+ // Shared Issues identity contract + data shapes for the live-issues queue.
2
+ //
3
+ // k8s-ui owns these because the Issues queue presentation (IssuesView) is
4
+ // host-agnostic: Radar Hub feeds it fleet-resolved grouped issues, and OSS
5
+ // Radar feeds a single-cluster ("fleet of one") set. Hosts map their wire
6
+ // payloads onto these types; the component renders against them.
7
+ //
8
+ // Mirrors the grouped Issue model radar emits (internal/issues.GroupIssues →
9
+ // /api/issues, and the hub's /api/fleet/issues). IssueResourceRef intentionally
10
+ // matches the Checks queue's contract (components/checks/types.ts) and
11
+ // radar/pkg/audit.ResourceKey — so Issues and Checks share deep-links rather
12
+ // than forking a second convention.
13
+
14
+ /** Operational severity for live issues — distinct from the Checks 4-tier
15
+ * posture ladder on purpose (operational urgency vs compliance risk are
16
+ * separate axes). Matches radar's issues.Severity. */
17
+ export type IssueSeverity = 'critical' | 'warning';
18
+
19
+ /** Ordered worst→least. */
20
+ export const ISSUE_SEVERITIES: IssueSeverity[] = ['critical', 'warning'];
21
+
22
+ export const ISSUE_SEVERITY_RANK: Record<IssueSeverity, number> = {
23
+ critical: 2,
24
+ warning: 1,
25
+ };
26
+
27
+ export function isIssueSeverity(s: string): s is IssueSeverity {
28
+ return s === 'critical' || s === 'warning';
29
+ }
30
+
31
+ /**
32
+ * Canonical resource identity. `group` is '' for the core API group;
33
+ * `namespace` is '' for cluster-scoped resources. `cluster_id` scopes the ref
34
+ * to its source cluster (the hub injects it; single-cluster OSS leaves it
35
+ * undefined). Same shape as Checks' CheckResourceRef so deep-link plumbing is
36
+ * shared.
37
+ */
38
+ export interface IssueResourceRef {
39
+ cluster_id?: string;
40
+ // group/namespace are optional to match the Go wire (omitempty): a
41
+ // cluster-scoped or core-group member (Node, a core/v1 object) arrives
42
+ // without them. Consumers default to '' (subjectRef/memberRef do this).
43
+ group?: string;
44
+ kind: string;
45
+ namespace?: string;
46
+ name: string;
47
+ }
48
+
49
+ /** Rollup of the underlying resources folded into a grouped issue, by kind
50
+ * bucket. Empty for single-resource issues (no fan-out). Mirrors the Go
51
+ * issues.Affected struct. */
52
+ export interface IssueAffected {
53
+ pods?: number;
54
+ workloads?: number;
55
+ services?: number;
56
+ pvcs?: number;
57
+ nodes?: number;
58
+ }
59
+
60
+ /**
61
+ * A grouped live issue — one row of the triage queue. Subject (kind/group/
62
+ * namespace/name) is the topmost owner when the rows folded under a workload,
63
+ * else the resource itself; `members` are the folded underlying resources
64
+ * (the fan-out), bounded inline with `members_truncated`. Mirrors the Go
65
+ * issues.Issue after GroupIssues.
66
+ */
67
+ export interface Issue {
68
+ id: string;
69
+ severity: IssueSeverity;
70
+ /** Detection channel (problem|missing_ref|scheduling|condition) — an output
71
+ * label, not the triage axis. */
72
+ source: string;
73
+ /** Symptom taxonomy (image_pull_failed, crashloop, …) — the triage axis. */
74
+ category: string;
75
+ /** Coarse rollup of category (startup|runtime|networking|…). Server-emitted
76
+ * so the UI never needs its own category→group map. */
77
+ category_group: string;
78
+ /** Subject kind bucket (workload|service|pvc|ingress|node|unknown). */
79
+ grouping_scope: string;
80
+
81
+ // Subject identity (the grouped thing). group is omitted for the core API
82
+ // group, namespace for cluster-scoped subjects — both optional to match the
83
+ // wire (radar emits them omitempty).
84
+ cluster_id?: string;
85
+ cluster_name?: string;
86
+ group?: string;
87
+ kind: string;
88
+ namespace?: string;
89
+ name: string;
90
+
91
+ reason: string;
92
+ message?: string;
93
+ first_seen?: string;
94
+ last_seen?: string;
95
+ /** Affected-resource fan-out, EXCLUDING the subject (the row header).
96
+ * 0/omitted for a single-resource issue; e.g. 50 for one Deployment's
97
+ * 50 crashlooping pods. Exposed to API/MCP/CEL consumers, not just here. */
98
+ count?: number;
99
+
100
+ affected?: IssueAffected;
101
+ members?: IssueResourceRef[];
102
+ members_truncated?: boolean;
103
+
104
+ // Pod crash context carried from the representative member.
105
+ restart_count?: number;
106
+ last_terminated_reason?: string;
107
+ }
108
+
109
+ /** subjectRef builds a deep-linkable ref for an issue's subject — the row's
110
+ * cluster_id threaded onto its group/kind/namespace/name. */
111
+ export function subjectRef(issue: Issue): IssueResourceRef {
112
+ return {
113
+ cluster_id: issue.cluster_id,
114
+ group: issue.group ?? '',
115
+ kind: issue.kind,
116
+ namespace: issue.namespace ?? '',
117
+ name: issue.name,
118
+ };
119
+ }
120
+
121
+ /** memberRef threads the issue's cluster_id onto a member ref (members carry
122
+ * no cluster_id of their own — every member shares the issue's cluster). */
123
+ export function memberRef(issue: Issue, member: IssueResourceRef): IssueResourceRef {
124
+ // Normalize the same wire-omitted optionals subjectRef does: Go's Ref.Group /
125
+ // Ref.Namespace are omitempty, so core-API members (Pods) arrive with group /
126
+ // namespace undefined — left raw they'd interpolate "undefined" into host
127
+ // deep-links / React keys and break callbacks that assume a string.
128
+ return {
129
+ ...member,
130
+ group: member.group ?? '',
131
+ namespace: member.namespace ?? '',
132
+ cluster_id: issue.cluster_id,
133
+ };
134
+ }
135
+
136
+ /**
137
+ * compareIssues is the queue's stable sort order (extracted from IssuesView so
138
+ * it can be unit-tested). Severity first (critical before warning), then ONSET
139
+ * — first_seen DESC, deliberately NOT last_seen: last_seen bumps to compose-time
140
+ * on every poll, so sorting by it would reshuffle same-severity rows on each
141
+ * refetch. The remaining keys (cluster → namespace → name → id) are a fully
142
+ * deterministic tiebreak so the order never churns under auto-refresh.
143
+ */
144
+ export function compareIssues(a: Issue, b: Issue): number {
145
+ const r = ISSUE_SEVERITY_RANK[b.severity] - ISSUE_SEVERITY_RANK[a.severity];
146
+ if (r !== 0) return r;
147
+ const fa = a.first_seen ?? '';
148
+ const fb = b.first_seen ?? '';
149
+ if (fa !== fb) return fb.localeCompare(fa);
150
+ const c = (a.cluster_name ?? '').localeCompare(b.cluster_name ?? '');
151
+ if (c !== 0) return c;
152
+ const ns = (a.namespace ?? '').localeCompare(b.namespace ?? '');
153
+ if (ns !== 0) return ns;
154
+ const nm = a.name.localeCompare(b.name);
155
+ if (nm !== 0) return nm;
156
+ return a.id.localeCompare(b.id);
157
+ }
158
+
159
+ /**
160
+ * normalizeImagePullMessage turns a raw containerd/CRI image-pull error — which
161
+ * is verbose and re-quotes the image ref at every wrapped layer ("Back-off
162
+ * pulling image X: ErrImagePull: rpc error: code = NotFound desc = failed to
163
+ * pull and unpack image X: failed to resolve reference X: X: not found") — into
164
+ * a short headline: cause + the image ref once. Returns null for shapes it
165
+ * doesn't recognize, so the caller falls back to the raw string.
166
+ */
167
+ export function normalizeImagePullMessage(raw: string): string | null {
168
+ if (!raw) return null;
169
+ const ref = raw.match(/image "([^"]+)"/)?.[1];
170
+ const lower = raw.toLowerCase();
171
+ let cause: string | null = null;
172
+ if (/not\s*found|manifest\s*unknown|no such (image|manifest)/.test(lower)) cause = 'Image not found';
173
+ else if (/unauthorized|forbidden|denied|\b401\b|\b403\b|authentication required/.test(lower)) cause = 'Not authorized to pull image';
174
+ else if (/no such host|i\/o timeout|\btimeout\b|connection refused|dial tcp/.test(lower)) cause = 'Registry unreachable';
175
+ else if (/toomanyrequests|too many requests|rate limit/.test(lower)) cause = 'Registry rate-limited';
176
+ if (!cause) return null;
177
+ return ref ? `${cause}: ${ref}` : cause;
178
+ }
179
+
180
+ /**
181
+ * issueMessageParts splits an issue's message into the inline headline and the
182
+ * raw secondary detail. For image-pull issues the headline is a normalized
183
+ * one-liner and detail holds the original CRI string; for every other issue the
184
+ * headline IS the (already concise) message and detail is empty — no
185
+ * duplication. Gated on image-pull so a generic "not found" in, say, a
186
+ * missing_config_ref message ('secret "x" not found') is never mislabeled.
187
+ */
188
+ export function issueMessageParts(issue: Issue): { headline: string; detail: string } {
189
+ const raw = issue.message ?? '';
190
+ const isImagePull = issue.category === 'image_pull_failed' || /ImagePull|ErrImage|InvalidImageName|ImageInspect/i.test(issue.reason ?? '');
191
+ const normalized = isImagePull ? normalizeImagePullMessage(raw) : null;
192
+ if (normalized && normalized !== raw) return { headline: normalized, detail: raw };
193
+ return { headline: raw, detail: '' };
194
+ }
@@ -256,7 +256,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
256
256
  pods: [
257
257
  { key: 'name', label: 'Name' },
258
258
  { key: 'namespace', label: 'Namespace', width: 'w-48' },
259
- { key: 'containers', label: 'Containers', width: 'w-32' },
259
+ { key: 'containers', label: 'Containers', width: 'w-32', tooltip: 'Container readiness; hover each square for name and state' },
260
260
  { key: 'status', label: 'Status', width: 'w-40' },
261
261
  { key: 'cpu', label: 'CPU', width: 'w-40', tooltip: 'CPU usage / limit (marker = request)' },
262
262
  { key: 'memory', label: 'Memory', width: 'w-40', tooltip: 'Memory usage / limit (marker = request)' },
@@ -3996,23 +3996,6 @@ export function ResourcesView({
3996
3996
  <X className="w-3.5 h-3.5" />
3997
3997
  </button>
3998
3998
  )}
3999
- {/*
4000
- The sidebar's count badge shows the cluster-wide
4001
- total for the selected kind (from resourceCounts) and
4002
- deliberately stays unfiltered. When a search yields
4003
- zero results the user can think the badge is lying.
4004
- Spell out that the badge is the cluster total, not
4005
- the filtered count.
4006
- */}
4007
- {searchTerm && (() => {
4008
- const totalForKind = counts[selectedKind.group ? `${selectedKind.group}/${selectedKind.kind}` : selectedKind.kind] ?? 0
4009
- if (totalForKind === 0) return null
4010
- return (
4011
- <p className="text-xs mt-1 text-theme-text-disabled">
4012
- The sidebar shows {pluralize(totalForKind, selectedKind.kind)} in the cluster — the count is unfiltered.
4013
- </p>
4014
- )
4015
- })()}
4016
3999
  {namespaces.length > 0 && <p className="text-sm mt-1 text-theme-text-disabled">Searching in {namespaces.length === 1 ? `namespace: ${namespaces[0]}` : `${namespaces.length} namespaces`}</p>}
4017
4000
  {/* Show active filters as dismissible badges so user can clear them */}
4018
4001
  {(() => {
@@ -4933,7 +4916,7 @@ function PodCell({ resource, column }: { resource: any; column: string }) {
4933
4916
  const squares = getContainerSquareStates(resource)
4934
4917
  const hasInit = squares.some(s => s.isInit)
4935
4918
  return (
4936
- <div className="flex items-center gap-1">
4919
+ <div className="flex w-full items-center justify-center gap-1">
4937
4920
  {squares.map((sq, i) => {
4938
4921
  const showSeparator = hasInit && i > 0 && sq.isInit !== squares[i - 1].isInit
4939
4922
  const bgClass =
@@ -115,7 +115,7 @@ export function CAPIKubeadmControlPlaneRenderer({ data, onNavigate }: Props) {
115
115
 
116
116
  {/* Owned Machines hint */}
117
117
  <div className="px-3 py-1.5 text-xs text-theme-text-tertiary">
118
- Machines with label <code className="bg-theme-surface px-1 py-0.5 rounded text-[10px] font-mono select-all">cluster.x-k8s.io/control-plane-name={data.metadata?.name}</code>
118
+ Machines with label <code className="inline-code text-[10px] select-all">cluster.x-k8s.io/control-plane-name={data.metadata?.name}</code>
119
119
  </div>
120
120
 
121
121
  <ConditionsSection conditions={conditions} />
@@ -119,7 +119,7 @@ export function CAPIMachineDeploymentRenderer({ data, onNavigate }: Props) {
119
119
 
120
120
  {/* Owned Machines hint */}
121
121
  <div className="px-3 py-1.5 text-xs text-theme-text-tertiary">
122
- Machines with label <code className="bg-theme-surface px-1 py-0.5 rounded text-[10px] font-mono select-all">cluster.x-k8s.io/deployment-name={data.metadata?.name}</code>
122
+ Machines with label <code className="inline-code text-[10px] select-all">cluster.x-k8s.io/deployment-name={data.metadata?.name}</code>
123
123
  </div>
124
124
 
125
125
  <ConditionsSection conditions={conditions} />
@@ -92,7 +92,7 @@ export function CAPIMachineSetRenderer({ data, onNavigate }: Props) {
92
92
 
93
93
  {/* Owned Machines hint */}
94
94
  <div className="px-3 py-1.5 text-xs text-theme-text-tertiary">
95
- Machines with label <code className="bg-theme-surface px-1 py-0.5 rounded text-[10px] font-mono select-all">cluster.x-k8s.io/set-name={data.metadata?.name}</code>
95
+ Machines with label <code className="inline-code text-[10px] select-all">cluster.x-k8s.io/set-name={data.metadata?.name}</code>
96
96
  </div>
97
97
 
98
98
  <ConditionsSection conditions={conditions} />
@@ -50,7 +50,7 @@ export function CNPGPoolerRenderer({ data, onNavigate }: CNPGPoolerRendererProps
50
50
  <PropertyList>
51
51
  {authQuery && (
52
52
  <Property label="Auth Query" value={
53
- <code className="text-xs font-mono bg-theme-elevated px-1.5 py-0.5 rounded break-all">{authQuery}</code>
53
+ <code className="inline-code text-xs break-all">{authQuery}</code>
54
54
  } />
55
55
  )}
56
56
  {authQuerySecret && (
@@ -57,13 +57,13 @@ function CompositionBody({ data, onNavigate, revision }: CompositionRendererProp
57
57
  {xrdKind && (
58
58
  <Property
59
59
  label="Composite Kind"
60
- value={<span className="font-mono text-theme-text-secondary">{xrdKind}</span>}
60
+ value={<span className="inline-code">{xrdKind}</span>}
61
61
  />
62
62
  )}
63
63
  {compositeTypeRef.apiVersion && (
64
64
  <Property
65
65
  label="API Version"
66
- value={<span className="font-mono text-theme-text-tertiary text-xs">{compositeTypeRef.apiVersion}</span>}
66
+ value={<span className="inline-code text-xs">{compositeTypeRef.apiVersion}</span>}
67
67
  />
68
68
  )}
69
69
  {writeConnNs && <Property label="Connection Secret Namespace" value={writeConnNs} />}
@@ -149,7 +149,7 @@ function CompositionBody({ data, onNavigate, revision }: CompositionRendererProp
149
149
  return (
150
150
  <div key={i} className="card-inner text-sm flex items-center gap-2 flex-wrap">
151
151
  <span className="badge-sm status-neutral">{baseKind || 'Unknown'}</span>
152
- <span className="font-mono text-theme-text-secondary break-all">{res.name || `resource-${i}`}</span>
152
+ <span className="inline-code break-all">{res.name || `resource-${i}`}</span>
153
153
  {baseApiVersion && (
154
154
  <span className="text-theme-text-tertiary text-xs">{baseApiVersion}</span>
155
155
  )}
@@ -54,7 +54,7 @@ export function CrossplanePackageRenderer({ data, kindLabel, onNavigate }: Cross
54
54
 
55
55
  <Section title={kindLabel} icon={Package} defaultExpanded>
56
56
  <PropertyList>
57
- <Property label="Package" value={<span className="font-mono break-all">{pkg}</span>} />
57
+ <Property label="Package" value={<span className="inline-code break-all">{pkg}</span>} />
58
58
  {pullPolicy && <Property label="Pull Policy" value={pullPolicy} />}
59
59
  {data?.spec?.revisionActivationPolicy && (
60
60
  <Property label="Revision Activation" value={data.spec.revisionActivationPolicy} />
@@ -73,12 +73,12 @@ export function CrossplanePackageRenderer({ data, kindLabel, onNavigate }: Cross
73
73
  <Section title="Revision" icon={ScrollText} defaultExpanded>
74
74
  <PropertyList>
75
75
  {currentRevision && (
76
- <Property label="Current Revision" value={<span className="font-mono break-all">{currentRevision}</span>} />
76
+ <Property label="Current Revision" value={<span className="inline-code break-all">{currentRevision}</span>} />
77
77
  )}
78
78
  {data?.status?.currentIdentifier && (
79
79
  <Property
80
80
  label="Current Identifier"
81
- value={<span className="font-mono break-all">{data.status.currentIdentifier}</span>}
81
+ value={<span className="inline-code break-all">{data.status.currentIdentifier}</span>}
82
82
  />
83
83
  )}
84
84
  </PropertyList>
@@ -112,7 +112,7 @@ export function CrossplanePackageRenderer({ data, kindLabel, onNavigate }: Cross
112
112
  return (
113
113
  <div key={i} className="card-inner text-sm flex items-center gap-2 flex-wrap">
114
114
  <span className="badge-sm status-neutral">{depKind}</span>
115
- <span className="font-mono text-theme-text-secondary break-all">{ref || '-'}</span>
115
+ <span className="inline-code break-all">{ref || '-'}</span>
116
116
  {dep.version && (
117
117
  <span className="text-theme-text-tertiary text-xs">version: {dep.version}</span>
118
118
  )}
@@ -129,7 +129,7 @@ export function CrossplanePackageRenderer({ data, kindLabel, onNavigate }: Cross
129
129
  {objectRefs.map((ref: any, i: number) => (
130
130
  <div key={i} className="card-inner text-sm flex items-center gap-2 flex-wrap">
131
131
  <span className="badge-sm status-neutral">{ref.kind || 'Unknown'}</span>
132
- <span className="font-mono text-theme-text-secondary break-all">{ref.name}</span>
132
+ <span className="inline-code break-all">{ref.name}</span>
133
133
  {ref.apiVersion && (
134
134
  <span className="text-theme-text-tertiary text-xs">{ref.apiVersion}</span>
135
135
  )}
@@ -56,7 +56,7 @@ export function CrossplaneProviderConfigRenderer({ data, onNavigate }: Crossplan
56
56
  }
57
57
  />
58
58
  {secretRef.namespace && <Property label="Namespace" value={secretRef.namespace} />}
59
- {secretRef.key && <Property label="Key" value={<span className="font-mono">{secretRef.key}</span>} />}
59
+ {secretRef.key && <Property label="Key" value={<span className="inline-code">{secretRef.key}</span>} />}
60
60
  </PropertyList>
61
61
  </Section>
62
62
  )}
@@ -58,7 +58,7 @@ export function PingSourceRenderer({ data, onNavigate }: RendererProps) {
58
58
  <Property label="Timezone" value={spec.timezone} />
59
59
  <Property label="Content Type" value={spec.contentType} />
60
60
  <Property label="Data" value={spec.data ? (
61
- <span className="font-mono text-xs break-all">{spec.data.length > 200 ? spec.data.slice(0, 200) + '...' : spec.data}</span>
61
+ <span className="inline-code text-xs break-all">{spec.data.length > 200 ? spec.data.slice(0, 200) + '...' : spec.data}</span>
62
62
  ) : undefined} />
63
63
  <SinkProperty sink={spec.sink} ns={ns} onNavigate={onNavigate} />
64
64
  </PropertyList>