@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.
- package/package.json +6 -1
- package/src/components/charts/PrometheusChartsView.tsx +233 -0
- package/src/components/charts/index.ts +13 -0
- package/src/components/checks/ChecksView.tsx +5 -1
- package/src/components/gitops/GitOpsTableView.tsx +1 -2
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +3 -3
- package/src/components/issues/IssuesView.tsx +352 -0
- package/src/components/issues/index.ts +24 -0
- package/src/components/issues/issues.test.ts +100 -0
- package/src/components/issues/severity.ts +125 -0
- package/src/components/issues/types.ts +194 -0
- package/src/components/resources/ResourcesView.tsx +2 -19
- package/src/components/resources/renderers/CAPIKubeadmControlPlaneRenderer.tsx +1 -1
- package/src/components/resources/renderers/CAPIMachineDeploymentRenderer.tsx +1 -1
- package/src/components/resources/renderers/CAPIMachineSetRenderer.tsx +1 -1
- package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +1 -1
- package/src/components/resources/renderers/CompositionRenderer.tsx +3 -3
- package/src/components/resources/renderers/CrossplanePackageRenderer.tsx +5 -5
- package/src/components/resources/renderers/CrossplaneProviderConfigRenderer.tsx +1 -1
- package/src/components/resources/renderers/KnativeSourceRenderer.tsx +1 -1
- package/src/components/resources/renderers/PodRenderer.test.tsx +53 -0
- package/src/components/resources/renderers/PodRenderer.tsx +7 -1
- package/src/components/resources/renderers/VeleroBackupRenderer.tsx +1 -1
- package/src/components/resources/renderers/WorkloadRenderer.test.tsx +41 -0
- package/src/components/resources/renderers/WorkloadRenderer.tsx +49 -7
- package/src/components/resources/renderers/XRDRenderer.tsx +2 -2
- package/src/components/shared/DetailShell.tsx +107 -0
- package/src/components/shared/ManagedByChip.tsx +149 -16
- package/src/components/shared/ResourceRendererDispatch.test.tsx +45 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +13 -1
- package/src/components/shared/index.ts +2 -1
- package/src/components/timeline/TimelineSwimlanes.tsx +1320 -0
- package/src/components/timeline/index.ts +1 -0
- package/src/components/topology/K8sResourceNode.tsx +60 -60
- package/src/components/topology/TopologyFilterSidebar.tsx +25 -3
- package/src/components/topology/TopologyGraph.tsx +168 -52
- package/src/components/topology/TopologySearch.tsx +17 -8
- package/src/components/topology/topology-search-match.test.ts +1 -1
- package/src/components/topology/topology.css +18 -0
- package/src/components/ui/Tooltip.tsx +37 -2
- package/src/components/workload/WorkloadView.tsx +118 -112
- package/src/index.ts +5 -0
- package/src/theme/components.css +16 -0
- package/src/types/core.ts +3 -2
- package/src/utils/env-from.ts +3 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/replica-scalers.ts +9 -0
- 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="
|
|
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="
|
|
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="
|
|
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
|
|
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="
|
|
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="
|
|
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="
|
|
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="
|
|
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="
|
|
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="
|
|
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="
|
|
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="
|
|
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="
|
|
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="
|
|
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>
|