@skyhook-io/k8s-ui 1.8.5 → 1.8.7
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 +1 -1
- package/src/components/applications/ApplicationsView.tsx +2 -0
- package/src/components/audit/AuditAlerts.tsx +4 -0
- package/src/components/audit/AuditBadgeTooltip.test.tsx +30 -0
- package/src/components/audit/AuditBadgeTooltip.tsx +47 -0
- package/src/components/audit/AuditFindingsTable.tsx +4 -0
- package/src/components/audit/index.ts +1 -0
- package/src/components/gitops/GitOpsDetailLayout.tsx +3 -3
- package/src/components/gitops/GitOpsStatusBadge.tsx +9 -3
- package/src/components/gitops/GitOpsTableView.tsx +3 -1
- package/src/components/issues/IssuesView.tsx +9 -36
- package/src/components/issues/ResourceIssuesSection.tsx +142 -0
- package/src/components/issues/diagnostic.ts +64 -0
- package/src/components/issues/index.ts +2 -1
- package/src/components/issues/severity.ts +10 -9
- package/src/components/issues/types.ts +5 -0
- package/src/components/resources/ResourcesView.tsx +38 -2
- package/src/components/resources/cron-to-human.test.ts +41 -0
- package/src/components/resources/get-pod-phase-display.test.ts +115 -3
- package/src/components/resources/get-pod-problems.test.ts +18 -0
- package/src/components/resources/health-golden.test.ts +66 -0
- package/src/components/resources/renderers/CronJobRenderer.tsx +14 -7
- package/src/components/resources/renderers/EndpointSliceRenderer.tsx +4 -3
- package/src/components/resources/renderers/GRPCRouteRenderer.tsx +9 -15
- package/src/components/resources/renderers/GatewayClassRenderer.tsx +3 -11
- package/src/components/resources/renderers/GatewayRenderer.tsx +11 -35
- package/src/components/resources/renderers/HTTPRouteRenderer.tsx +10 -16
- package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +3 -6
- package/src/components/resources/renderers/IstioAuthorizationPolicyRenderer.tsx +24 -28
- package/src/components/resources/renderers/IstioGatewayRenderer.tsx +19 -26
- package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +20 -19
- package/src/components/resources/renderers/JobRenderer.tsx +12 -7
- package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +2 -2
- package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +9 -11
- package/src/components/resources/renderers/NodeRenderer.tsx +17 -8
- package/src/components/resources/renderers/PVCRenderer.tsx +7 -7
- package/src/components/resources/renderers/PodRenderer.tsx +28 -9
- package/src/components/resources/renderers/ServiceRenderer.tsx +23 -9
- package/src/components/resources/renderers/SimpleRouteRenderer.tsx +6 -12
- package/src/components/resources/renderers/TraefikIngressRouteRenderer.tsx +11 -10
- package/src/components/resources/renderers/TraefikMiddlewareRenderer.tsx +185 -0
- package/src/components/resources/renderers/TraefikServersTransportRenderer.tsx +84 -0
- package/src/components/resources/renderers/TraefikServiceRenderer.tsx +118 -0
- package/src/components/resources/renderers/TraefikTLSOptionRenderer.tsx +62 -0
- package/src/components/resources/renderers/WorkflowRenderer.tsx +1 -1
- package/src/components/resources/renderers/WorkloadRenderer.tsx +6 -3
- package/src/components/resources/renderers/badge-no-handrolled.test.tsx +66 -0
- package/src/components/resources/renderers/contour-cells.tsx +6 -5
- package/src/components/resources/renderers/index.ts +4 -0
- package/src/components/resources/renderers/istio-cells.tsx +16 -25
- package/src/components/resources/resource-utils-argo.test.ts +23 -0
- package/src/components/resources/resource-utils-argo.ts +5 -1
- package/src/components/resources/resource-utils-cnpg.ts +1 -1
- package/src/components/resources/resource-utils-keda.ts +17 -7
- package/src/components/resources/resource-utils.ts +206 -71
- package/src/components/shared/ResourceRendererDispatch.tsx +10 -0
- package/src/components/timeline/TimelineSwimlanes.tsx +1 -0
- package/src/components/timeline/shared.tsx +15 -4
- package/src/components/topology/K8sResourceNode.tsx +28 -1
- package/src/components/topology/TopologyControls.tsx +41 -9
- package/src/components/topology/layout.ts +11 -5
- package/src/components/ui/Badge.tsx +66 -3
- package/src/components/ui/ClusterName.tsx +12 -4
- package/src/components/ui/PaneLoader.tsx +30 -7
- package/src/components/ui/drawer-components.test.tsx +35 -0
- package/src/components/ui/drawer-components.tsx +13 -1
- package/src/components/workload/WorkloadView.tsx +35 -5
- package/src/types/core.ts +136 -2
- package/src/utils/applications.test.ts +55 -1
- package/src/utils/applications.ts +28 -7
- package/src/utils/asset-url.ts +13 -0
- package/src/utils/badge-colors.ts +7 -0
|
@@ -129,11 +129,12 @@ import {
|
|
|
129
129
|
podMatchesProblemCategory,
|
|
130
130
|
SEVERITY_DOT_COLOR,
|
|
131
131
|
} from './resource-utils'
|
|
132
|
-
import { SEVERITY_BADGE, EVENT_TYPE_COLORS } from '../../utils/badge-colors'
|
|
132
|
+
import { SEVERITY_BADGE, EVENT_TYPE_COLORS, SEVERITY_TEXT } from '../../utils/badge-colors'
|
|
133
133
|
import { pluralize } from '../../utils/pluralize'
|
|
134
134
|
import { getPodGpuCount, getNodeGpuCount } from '../../utils/extended-resources'
|
|
135
135
|
import { type CustomColumnDef, type CustomColumnSource, customColumnKey, readCustomColumnValue, sanitizeCustomColumnDefs } from '../../utils/custom-columns'
|
|
136
136
|
import { Tooltip } from '../ui/Tooltip'
|
|
137
|
+
import { AuditBadgeTooltip, type AuditBadgeMessage } from '../audit/AuditBadgeTooltip'
|
|
137
138
|
// CRD-specific cell components (extracted)
|
|
138
139
|
import { GitRepositoryCell, OCIRepositoryCell, HelmRepositoryCell, KustomizationCell, FluxHelmReleaseCell, FluxAlertCell } from './renderers/flux-cells'
|
|
139
140
|
import { ArgoApplicationCell, ArgoApplicationSetCell, ArgoAppProjectCell } from './renderers/argo-cells'
|
|
@@ -1800,6 +1801,9 @@ interface ResourcesViewData {
|
|
|
1800
1801
|
onNavigate?: (path: string, options?: { replace?: boolean }) => void
|
|
1801
1802
|
certExpiry?: Record<string, { expired?: boolean; daysLeft: number }>
|
|
1802
1803
|
certExpiryError?: boolean
|
|
1804
|
+
// Cluster Audit findings for the listed kind, keyed by "namespace/name" (the
|
|
1805
|
+
// list shows one kind at a time, so ns/name is unambiguous). Host-injected.
|
|
1806
|
+
auditBadges?: Record<string, { danger: number; warning: number; messages?: AuditBadgeMessage[] }>
|
|
1803
1807
|
onOpenLogs?: (params: { namespace: string; podName: string; containers: string[]; containerName?: string }) => void
|
|
1804
1808
|
onOpenWorkloadLogs?: (params: { namespace: string; workloadKind: string; workloadName: string }) => void
|
|
1805
1809
|
}
|
|
@@ -1852,6 +1856,8 @@ interface ResourcesViewProps {
|
|
|
1852
1856
|
topNodeMetrics?: TopNodeMetrics[]
|
|
1853
1857
|
certExpiry?: Record<string, { expired?: boolean; daysLeft: number }>
|
|
1854
1858
|
certExpiryError?: boolean
|
|
1859
|
+
// Cluster Audit findings for the selected kind, keyed by "namespace/name".
|
|
1860
|
+
auditBadges?: Record<string, { danger: number; warning: number; messages?: AuditBadgeMessage[] }>
|
|
1855
1861
|
// Pinned kinds
|
|
1856
1862
|
pinned?: Array<{ name: string; kind: string; group: string }>
|
|
1857
1863
|
togglePin?: (kind: { name: string; kind: string; group: string }) => void
|
|
@@ -2070,6 +2076,7 @@ export function ResourcesView({
|
|
|
2070
2076
|
topNodeMetrics,
|
|
2071
2077
|
certExpiry,
|
|
2072
2078
|
certExpiryError,
|
|
2079
|
+
auditBadges,
|
|
2073
2080
|
pinned = [],
|
|
2074
2081
|
togglePin = () => {},
|
|
2075
2082
|
isPinned = () => false,
|
|
@@ -4014,9 +4021,10 @@ export function ResourcesView({
|
|
|
4014
4021
|
onNavigate,
|
|
4015
4022
|
certExpiry,
|
|
4016
4023
|
certExpiryError,
|
|
4024
|
+
auditBadges,
|
|
4017
4025
|
onOpenLogs,
|
|
4018
4026
|
onOpenWorkloadLogs,
|
|
4019
|
-
}), [onNavigate, certExpiry, certExpiryError, onOpenLogs, onOpenWorkloadLogs])
|
|
4027
|
+
}), [onNavigate, certExpiry, certExpiryError, auditBadges, onOpenLogs, onOpenWorkloadLogs])
|
|
4020
4028
|
|
|
4021
4029
|
return (
|
|
4022
4030
|
<ResourcesViewDataContext.Provider value={resourcesViewDataContextValue}>
|
|
@@ -5155,6 +5163,7 @@ interface CellContentProps {
|
|
|
5155
5163
|
}
|
|
5156
5164
|
|
|
5157
5165
|
function CellContent({ resource, kind, column, group, majorityNodeMinorVersion, extraColumn, nameHref }: CellContentProps) {
|
|
5166
|
+
const { auditBadges } = useContext(ResourcesViewDataContext)
|
|
5158
5167
|
// Parent-injected extra columns short-circuit the built-in switch.
|
|
5159
5168
|
// Used by hosts that inject leading columns (e.g. a multi-cluster Cluster column).
|
|
5160
5169
|
if (extraColumn) {
|
|
@@ -5167,6 +5176,8 @@ function CellContent({ resource, kind, column, group, majorityNodeMinorVersion,
|
|
|
5167
5176
|
if (column === 'name') {
|
|
5168
5177
|
const isTerminating = !!meta.deletionTimestamp
|
|
5169
5178
|
const nameClass = clsx('text-sm font-medium truncate block', isTerminating ? 'text-theme-text-tertiary line-through' : 'text-theme-text-primary')
|
|
5179
|
+
const audit = auditBadges?.[`${meta.namespace || ''}/${meta.name}`]
|
|
5180
|
+
const auditTotal = audit ? audit.danger + audit.warning : 0
|
|
5170
5181
|
return (
|
|
5171
5182
|
<div className="flex items-center gap-1.5 min-w-0">
|
|
5172
5183
|
<Tooltip content={meta.name}>
|
|
@@ -5184,6 +5195,16 @@ function CellContent({ resource, kind, column, group, majorityNodeMinorVersion,
|
|
|
5184
5195
|
)}
|
|
5185
5196
|
</Tooltip>
|
|
5186
5197
|
<CopyNameButton name={meta.name} />
|
|
5198
|
+
{auditTotal > 0 && audit && (
|
|
5199
|
+
<Tooltip content={audit.messages && audit.messages.length > 0
|
|
5200
|
+
? <AuditBadgeTooltip messages={audit.messages} />
|
|
5201
|
+
: `${auditTotal} audit ${auditTotal === 1 ? 'finding' : 'findings'}${audit.danger > 0 ? ` · ${audit.danger} danger` : ''}`}>
|
|
5202
|
+
<span className={clsx('shrink-0 inline-flex items-center gap-0.5 text-[10px] font-medium cursor-help', audit.danger > 0 ? SEVERITY_TEXT.error : SEVERITY_TEXT.warning)}>
|
|
5203
|
+
<AlertTriangle className="w-3 h-3" />
|
|
5204
|
+
{auditTotal}
|
|
5205
|
+
</span>
|
|
5206
|
+
</Tooltip>
|
|
5207
|
+
)}
|
|
5187
5208
|
{isTerminating && (
|
|
5188
5209
|
<Tooltip content="Resource is being deleted (has deletionTimestamp set). May be stuck due to finalizers.">
|
|
5189
5210
|
<span className="shrink-0 flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium bg-red-500/15 text-red-600 dark:text-red-400 rounded">
|
|
@@ -6022,6 +6043,7 @@ function ReplicaSetCell({ resource, column }: { resource: any; column: string })
|
|
|
6022
6043
|
}
|
|
6023
6044
|
|
|
6024
6045
|
function ServiceCell({ resource, column }: { resource: any; column: string }) {
|
|
6046
|
+
const { auditBadges } = useContext(ResourcesViewDataContext)
|
|
6025
6047
|
switch (column) {
|
|
6026
6048
|
case 'type': {
|
|
6027
6049
|
const status = getServiceStatus(resource)
|
|
@@ -6042,6 +6064,20 @@ function ServiceCell({ resource, column }: { resource: any; column: string }) {
|
|
|
6042
6064
|
)
|
|
6043
6065
|
}
|
|
6044
6066
|
case 'endpoints': {
|
|
6067
|
+
// getServiceEndpointsStatus can't see live pods, so it optimistically
|
|
6068
|
+
// reports "Active" for any service with a selector. The audit's
|
|
6069
|
+
// serviceNoMatchingPods check DOES resolve the selector against live pods —
|
|
6070
|
+
// the only badge-worthy finding a Service can carry — so when it fired,
|
|
6071
|
+
// trust it over the guess instead of showing a false-green "Active".
|
|
6072
|
+
const meta = resource.metadata || {}
|
|
6073
|
+
const flagged = auditBadges?.[`${meta.namespace || ''}/${meta.name}`]
|
|
6074
|
+
if (flagged && flagged.danger + flagged.warning > 0) {
|
|
6075
|
+
return (
|
|
6076
|
+
<span className={clsx('badge', flagged.danger > 0 ? 'status-unhealthy' : 'status-degraded')}>
|
|
6077
|
+
No endpoints
|
|
6078
|
+
</span>
|
|
6079
|
+
)
|
|
6080
|
+
}
|
|
6045
6081
|
const { status, color } = getServiceEndpointsStatus(resource)
|
|
6046
6082
|
return (
|
|
6047
6083
|
<span className={clsx('badge', color)}>
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { cronToHuman } from './resource-utils'
|
|
3
|
+
|
|
4
|
+
describe('cronToHuman', () => {
|
|
5
|
+
it.each([
|
|
6
|
+
// The reported bug: step-minute with a wildcard hour was caught by the
|
|
7
|
+
// "Every hour at :MM" branch before the interval branch.
|
|
8
|
+
['*/5 * * * *', 'Every 5 minutes'],
|
|
9
|
+
['*/15 * * * *', 'Every 15 minutes'],
|
|
10
|
+
['*/30 * * * *', 'Every 30 minutes'],
|
|
11
|
+
['*/1 * * * *', 'Every minute'],
|
|
12
|
+
// Literal minute must still read as "Every hour at :MM" (not regressed).
|
|
13
|
+
['30 * * * *', 'Every hour at :30'],
|
|
14
|
+
['0 * * * *', 'Every hour at :00'],
|
|
15
|
+
['5 * * * *', 'Every hour at :05'],
|
|
16
|
+
// Every minute.
|
|
17
|
+
['* * * * *', 'Every minute'],
|
|
18
|
+
// Step-hour (the #952 fix) must still work.
|
|
19
|
+
['0 */6 * * *', 'Every 6 hours'],
|
|
20
|
+
['0 */1 * * *', 'Every hour'],
|
|
21
|
+
// Daily patterns.
|
|
22
|
+
['0 0 * * *', 'Daily at midnight'],
|
|
23
|
+
['0 9 * * *', 'Daily at 9:00'],
|
|
24
|
+
// Weekdays — only when hour:minute are literal.
|
|
25
|
+
['0 9 * * 1-5', 'Weekdays at 9:00'],
|
|
26
|
+
['0 9 * * MON-FRI', 'Weekdays at 9:00'],
|
|
27
|
+
['30 14 * * 1-5', 'Weekdays at 14:30'],
|
|
28
|
+
// Constrained step-minute must NOT claim an unconstrained interval — these run
|
|
29
|
+
// only in a window, so we fall back to the raw cron rather than mislead.
|
|
30
|
+
['*/5 9 * * *', '*/5 9 * * *'],
|
|
31
|
+
['*/5 * * * 1-5', '*/5 * * * 1-5'],
|
|
32
|
+
['*/5 9 * * 1-5', '*/5 9 * * 1-5'],
|
|
33
|
+
['*/1 9 * * *', '*/1 9 * * *'],
|
|
34
|
+
// Falls back to the raw expression for shapes we don't humanize.
|
|
35
|
+
['15 14 1 * *', '15 14 1 * *'],
|
|
36
|
+
['*/5', '*/5'],
|
|
37
|
+
['', '-'],
|
|
38
|
+
])('humanizes %s -> %s', (cron, expected) => {
|
|
39
|
+
expect(cronToHuman(cron)).toBe(expected)
|
|
40
|
+
})
|
|
41
|
+
})
|
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import { getPodPhaseDisplay } from './resource-utils'
|
|
2
|
+
import { getPodPhaseDisplay, getPodStatus } from './resource-utils'
|
|
3
|
+
|
|
4
|
+
describe('getPodStatus', () => {
|
|
5
|
+
it('keeps a Succeeded pod neutral despite a sidecar that OOMed before completion', () => {
|
|
6
|
+
const succeededWithOomedSidecar = {
|
|
7
|
+
status: {
|
|
8
|
+
phase: 'Succeeded',
|
|
9
|
+
containerStatuses: [
|
|
10
|
+
{ name: 'main', ready: false, state: { terminated: { reason: 'Completed', exitCode: 0 } } },
|
|
11
|
+
{ name: 'sidecar', ready: false, state: { terminated: { reason: 'OOMKilled', exitCode: 137 } } },
|
|
12
|
+
],
|
|
13
|
+
},
|
|
14
|
+
}
|
|
15
|
+
const r = getPodStatus(succeededWithOomedSidecar)
|
|
16
|
+
expect(r.text).toBe('Completed')
|
|
17
|
+
expect(r.level).toBe('neutral')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('still flags a crash-looping (non-terminal) pod as unhealthy', () => {
|
|
21
|
+
const crashing = {
|
|
22
|
+
status: {
|
|
23
|
+
phase: 'Running',
|
|
24
|
+
containerStatuses: [{ name: 'app', ready: false, state: { waiting: { reason: 'CrashLoopBackOff' } } }],
|
|
25
|
+
},
|
|
26
|
+
}
|
|
27
|
+
expect(getPodStatus(crashing).level).toBe('unhealthy')
|
|
28
|
+
})
|
|
29
|
+
})
|
|
3
30
|
|
|
4
31
|
const podRunningHealthy = {
|
|
5
32
|
status: {
|
|
@@ -192,12 +219,59 @@ describe('getPodPhaseDisplay', () => {
|
|
|
192
219
|
expect(r.level).toBe('unhealthy')
|
|
193
220
|
})
|
|
194
221
|
|
|
195
|
-
it('marks
|
|
222
|
+
it('marks a long-terminating pod as Terminating + degraded (stuck shutdown)', () => {
|
|
196
223
|
const r = getPodPhaseDisplay(podTerminating)
|
|
197
224
|
expect(r.text).toBe('Running — Terminating')
|
|
198
225
|
expect(r.level).toBe('degraded')
|
|
199
226
|
})
|
|
200
227
|
|
|
228
|
+
it('keeps a recently-terminating pod neutral (graceful shutdown in progress)', () => {
|
|
229
|
+
const recent = {
|
|
230
|
+
metadata: { deletionTimestamp: new Date(Date.now() - 30 * 1000).toISOString() },
|
|
231
|
+
status: { phase: 'Running', containerStatuses: [{ name: 'app', ready: true, restartCount: 0 }] },
|
|
232
|
+
}
|
|
233
|
+
const r = getPodPhaseDisplay(recent)
|
|
234
|
+
expect(r.text).toBe('Running — Terminating')
|
|
235
|
+
expect(r.level).toBe('neutral')
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
it('treats a completing pod (container exited 0, not ready) as healthy', () => {
|
|
239
|
+
const completing = {
|
|
240
|
+
status: {
|
|
241
|
+
phase: 'Running',
|
|
242
|
+
containerStatuses: [
|
|
243
|
+
{ name: 'app', ready: false, restartCount: 0, state: { terminated: { reason: 'Completed', exitCode: 0 } } },
|
|
244
|
+
],
|
|
245
|
+
},
|
|
246
|
+
}
|
|
247
|
+
const r = getPodPhaseDisplay(completing)
|
|
248
|
+
expect(r.text).toBe('Running')
|
|
249
|
+
expect(r.level).toBe('healthy')
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it('keeps a Failed pod unhealthy even while it is terminating', () => {
|
|
253
|
+
const failedTerminating = {
|
|
254
|
+
metadata: { deletionTimestamp: new Date().toISOString() },
|
|
255
|
+
status: { phase: 'Failed' },
|
|
256
|
+
}
|
|
257
|
+
const r = getPodPhaseDisplay(failedTerminating)
|
|
258
|
+
expect(r.text).toBe('Failed')
|
|
259
|
+
expect(r.level).toBe('unhealthy')
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
it('surfaces a fatal container failure even while the pod is terminating', () => {
|
|
263
|
+
const terminatingCrash = {
|
|
264
|
+
metadata: { deletionTimestamp: new Date().toISOString() },
|
|
265
|
+
status: {
|
|
266
|
+
phase: 'Running',
|
|
267
|
+
containerStatuses: [{ name: 'app', ready: false, state: { waiting: { reason: 'CrashLoopBackOff' } } }],
|
|
268
|
+
},
|
|
269
|
+
}
|
|
270
|
+
const r = getPodPhaseDisplay(terminatingCrash)
|
|
271
|
+
expect(r.text).toBe('Running — CrashLoopBackOff')
|
|
272
|
+
expect(r.level).toBe('unhealthy')
|
|
273
|
+
})
|
|
274
|
+
|
|
201
275
|
it('falls back to Unknown for missing/unknown phase', () => {
|
|
202
276
|
expect(getPodPhaseDisplay({}).level).toBe('unknown')
|
|
203
277
|
expect(getPodPhaseDisplay({}).phase).toBe('Unknown')
|
|
@@ -206,10 +280,48 @@ describe('getPodPhaseDisplay', () => {
|
|
|
206
280
|
|
|
207
281
|
it('handles Succeeded/Pending/Failed phases', () => {
|
|
208
282
|
expect(getPodPhaseDisplay({ status: { phase: 'Succeeded' } }).level).toBe('neutral')
|
|
209
|
-
|
|
283
|
+
// A freshly-created Pending pod is benign (neutral), not degraded.
|
|
284
|
+
expect(getPodPhaseDisplay({ status: { phase: 'Pending' } }).level).toBe('neutral')
|
|
210
285
|
expect(getPodPhaseDisplay({ status: { phase: 'Failed' } }).level).toBe('unhealthy')
|
|
211
286
|
})
|
|
212
287
|
|
|
288
|
+
it('flags Unschedulable immediately, but gives plain young Pending a grace window', () => {
|
|
289
|
+
// The scheduler tried and failed — surface it now, no grace (matches backend).
|
|
290
|
+
const unsched = {
|
|
291
|
+
status: { phase: 'Pending', conditions: [{ type: 'PodScheduled', status: 'False', reason: 'Unschedulable' }] },
|
|
292
|
+
}
|
|
293
|
+
expect(getPodPhaseDisplay(unsched).text).toBe('Pending — Unschedulable')
|
|
294
|
+
expect(getPodPhaseDisplay(unsched).level).toBe('degraded')
|
|
295
|
+
|
|
296
|
+
// Plain Pending (not yet placed) is benign while young, degraded once stuck.
|
|
297
|
+
expect(getPodPhaseDisplay({ status: { phase: 'Pending' } }).level).toBe('neutral')
|
|
298
|
+
const stuckPending = {
|
|
299
|
+
metadata: { creationTimestamp: new Date(Date.now() - 10 * 60 * 1000).toISOString() },
|
|
300
|
+
status: { phase: 'Pending' },
|
|
301
|
+
}
|
|
302
|
+
expect(getPodPhaseDisplay(stuckPending).level).toBe('degraded')
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
it('flags a failing init container immediately, even on a young Pending pod', () => {
|
|
306
|
+
const initFail = {
|
|
307
|
+
metadata: { creationTimestamp: new Date().toISOString() },
|
|
308
|
+
status: {
|
|
309
|
+
phase: 'Pending',
|
|
310
|
+
initContainerStatuses: [{ name: 'init', ready: false, state: { waiting: { reason: 'ImagePullBackOff' } } }],
|
|
311
|
+
},
|
|
312
|
+
}
|
|
313
|
+
const r = getPodPhaseDisplay(initFail)
|
|
314
|
+
expect(r.text).toBe('Pending — ImagePullBackOff')
|
|
315
|
+
expect(r.level).toBe('unhealthy')
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
it('flags broadened fatal reasons like InvalidImageName', () => {
|
|
319
|
+
const bad = {
|
|
320
|
+
status: { phase: 'Pending', containerStatuses: [{ name: 'app', state: { waiting: { reason: 'InvalidImageName' } } }] },
|
|
321
|
+
}
|
|
322
|
+
expect(getPodPhaseDisplay(bad).level).toBe('unhealthy')
|
|
323
|
+
})
|
|
324
|
+
|
|
213
325
|
it('flags ErrImagePull as unhealthy', () => {
|
|
214
326
|
const r = getPodPhaseDisplay(podErrImagePull)
|
|
215
327
|
expect(r.text).toBe('Pending — ErrImagePull')
|
|
@@ -124,4 +124,22 @@ describe('getPodProblems', () => {
|
|
|
124
124
|
}),
|
|
125
125
|
).not.toContainEqual(expect.objectContaining({ message: 'Sandbox Startup Stalled' }))
|
|
126
126
|
})
|
|
127
|
+
|
|
128
|
+
it('does not flag a completing Job pod (Running, container exited 0, Ready=false) as Not Ready', () => {
|
|
129
|
+
expect(
|
|
130
|
+
getPodProblems({
|
|
131
|
+
status: {
|
|
132
|
+
phase: 'Running',
|
|
133
|
+
containerStatuses: [
|
|
134
|
+
{
|
|
135
|
+
name: 'job',
|
|
136
|
+
ready: false,
|
|
137
|
+
restartCount: 0,
|
|
138
|
+
state: { terminated: { reason: 'Completed', exitCode: 0 } },
|
|
139
|
+
},
|
|
140
|
+
],
|
|
141
|
+
},
|
|
142
|
+
}),
|
|
143
|
+
).not.toContainEqual(expect.objectContaining({ message: 'Not Ready' }))
|
|
144
|
+
})
|
|
127
145
|
})
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
import { dirname, resolve } from 'node:path'
|
|
5
|
+
import {
|
|
6
|
+
getPodStatus,
|
|
7
|
+
getWorkloadStatus,
|
|
8
|
+
getJobStatus,
|
|
9
|
+
getCronJobStatus,
|
|
10
|
+
getPVCStatus,
|
|
11
|
+
type HealthLevel,
|
|
12
|
+
} from './resource-utils'
|
|
13
|
+
|
|
14
|
+
// Cross-language health contract. This loads the SAME fixture as the Go test
|
|
15
|
+
// (pkg/health/golden_crosslang_test.go) and asserts the TS table classifiers
|
|
16
|
+
// produce the level pkg/health recorded. pkg/health is the source of truth; this
|
|
17
|
+
// is the anti-drift gate that keeps the two implementations from diverging.
|
|
18
|
+
//
|
|
19
|
+
// If this fails after a backend health change, the TS classifier in
|
|
20
|
+
// resource-utils.ts must be updated to match — not the other way round.
|
|
21
|
+
|
|
22
|
+
interface GoldenVector {
|
|
23
|
+
name: string
|
|
24
|
+
kind: string
|
|
25
|
+
level: HealthLevel
|
|
26
|
+
object: any
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
30
|
+
// src/components/resources -> repo root is five levels up, then pkg/health/testdata.
|
|
31
|
+
const fixturePath = resolve(here, '../../../../../pkg/health/testdata/golden_vectors.json')
|
|
32
|
+
const vectors: GoldenVector[] = JSON.parse(readFileSync(fixturePath, 'utf8')).vectors
|
|
33
|
+
|
|
34
|
+
// Map a fixture kind onto the TS classifier that backs its table badge.
|
|
35
|
+
function classify(kind: string, object: any): HealthLevel {
|
|
36
|
+
switch (kind) {
|
|
37
|
+
case 'Pod':
|
|
38
|
+
return getPodStatus(object).level
|
|
39
|
+
case 'Deployment':
|
|
40
|
+
return getWorkloadStatus(object, 'deployments').level
|
|
41
|
+
case 'StatefulSet':
|
|
42
|
+
return getWorkloadStatus(object, 'statefulsets').level
|
|
43
|
+
case 'DaemonSet':
|
|
44
|
+
return getWorkloadStatus(object, 'daemonsets').level
|
|
45
|
+
case 'Job':
|
|
46
|
+
return getJobStatus(object).level
|
|
47
|
+
case 'CronJob':
|
|
48
|
+
return getCronJobStatus(object).level
|
|
49
|
+
case 'PersistentVolumeClaim':
|
|
50
|
+
return getPVCStatus(object).level
|
|
51
|
+
default:
|
|
52
|
+
throw new Error(`golden vector kind "${kind}" has no TS classifier mapping`)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe('health golden vectors (cross-language contract with pkg/health)', () => {
|
|
57
|
+
it('loaded a non-empty fixture shared with the Go test', () => {
|
|
58
|
+
expect(vectors.length).toBeGreaterThan(0)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
for (const v of vectors) {
|
|
62
|
+
it(`${v.kind}: ${v.name}`, () => {
|
|
63
|
+
expect(classify(v.kind, v.object)).toBe(v.level)
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
})
|
|
@@ -15,17 +15,24 @@ export function CronJobRenderer({ data, onNavigate }: CronJobRendererProps) {
|
|
|
15
15
|
const isSuspended = spec.suspend === true
|
|
16
16
|
const hasNeverRun = !status.lastScheduleTime
|
|
17
17
|
|
|
18
|
-
//
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
// "Recent failures" means the latest scheduled run didn't reach success.
|
|
19
|
+
// Only meaningful once nothing is running — while a job is active the most
|
|
20
|
+
// recent run is simply still in flight, not failing. With concurrencyPolicy
|
|
21
|
+
// Allow an older failed run can overlap a new active one; CronJob status only
|
|
22
|
+
// carries aggregate timestamps, so we accept missing that rare case here (the
|
|
23
|
+
// failed Job still surfaces in the job list / topology) rather than reviving
|
|
24
|
+
// the false positive that fired on every normal in-flight run.
|
|
25
|
+
const activeJobs = status.active?.length || 0
|
|
26
|
+
const lastSchedule = status.lastScheduleTime ? new Date(status.lastScheduleTime).getTime() : 0
|
|
27
|
+
const lastSuccess = status.lastSuccessfulTime ? new Date(status.lastSuccessfulTime).getTime() : 0
|
|
28
|
+
const recentFailures = activeJobs === 0 && lastSchedule > 0 && lastSchedule > lastSuccess
|
|
22
29
|
|
|
23
30
|
return (
|
|
24
31
|
<>
|
|
25
|
-
{/* Suspended
|
|
32
|
+
{/* Suspended is an intentional operator state, not a fault — keep it informational. */}
|
|
26
33
|
{isSuspended && (
|
|
27
34
|
<AlertBanner
|
|
28
|
-
variant="
|
|
35
|
+
variant="info"
|
|
29
36
|
icon={Pause}
|
|
30
37
|
title="CronJob Suspended"
|
|
31
38
|
message="No new jobs will be scheduled until this CronJob is resumed."
|
|
@@ -42,7 +49,7 @@ export function CronJobRenderer({ data, onNavigate }: CronJobRendererProps) {
|
|
|
42
49
|
)}
|
|
43
50
|
|
|
44
51
|
{/* Recent failures warning */}
|
|
45
|
-
{recentFailures && (
|
|
52
|
+
{recentFailures && !isSuspended && (
|
|
46
53
|
<AlertBanner
|
|
47
54
|
variant="error"
|
|
48
55
|
title="Recent Jobs Failing"
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Radio, Server, Waypoints } from 'lucide-react'
|
|
2
2
|
import { clsx } from 'clsx'
|
|
3
3
|
import { Section, PropertyList, Property } from '../../ui/drawer-components'
|
|
4
|
+
import { Badge } from '../../ui/Badge'
|
|
4
5
|
import type { ResourceRef } from '../../../types'
|
|
5
6
|
|
|
6
7
|
interface EndpointSliceRendererProps {
|
|
@@ -69,7 +70,7 @@ export function EndpointSliceRenderer({ data, onNavigate }: EndpointSliceRendere
|
|
|
69
70
|
)}
|
|
70
71
|
</div>
|
|
71
72
|
<div className="flex items-center gap-2 shrink-0">
|
|
72
|
-
<
|
|
73
|
+
<Badge size="sm" protocol={port.protocol || 'TCP'}>{port.protocol || 'TCP'}</Badge>
|
|
73
74
|
<span className="font-mono text-theme-text-secondary">{port.port ?? '-'}</span>
|
|
74
75
|
</div>
|
|
75
76
|
</div>
|
|
@@ -92,9 +93,9 @@ export function EndpointSliceRenderer({ data, onNavigate }: EndpointSliceRendere
|
|
|
92
93
|
<div className="min-w-0 space-y-1">
|
|
93
94
|
<div className="flex flex-wrap items-center gap-1.5">
|
|
94
95
|
{(endpoint.addresses || []).map((address: string) => (
|
|
95
|
-
<
|
|
96
|
+
<Badge key={address} size="sm" tone="structural" className="font-mono">
|
|
96
97
|
{address}
|
|
97
|
-
</
|
|
98
|
+
</Badge>
|
|
98
99
|
))}
|
|
99
100
|
</div>
|
|
100
101
|
{targetLabel && (
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Globe, ArrowRight, Network, Filter } from 'lucide-react'
|
|
2
|
-
import { clsx } from 'clsx'
|
|
3
2
|
import { Section, PropertyList, Property, ConditionsSection, AlertBanner, ResourceRefBadge } from '../../ui/drawer-components'
|
|
3
|
+
import { Badge } from '../../ui/Badge'
|
|
4
4
|
import type { ResourceRef } from '../../../types'
|
|
5
5
|
|
|
6
6
|
interface GRPCRouteRendererProps {
|
|
@@ -77,7 +77,7 @@ export function GRPCRouteRenderer({ data, onNavigate }: GRPCRouteRendererProps)
|
|
|
77
77
|
hostnames.length > 0 ? (
|
|
78
78
|
<div className="flex flex-wrap gap-1">
|
|
79
79
|
{hostnames.map((h: string) => (
|
|
80
|
-
<
|
|
80
|
+
<Badge key={h} tone="structural">{h}</Badge>
|
|
81
81
|
))}
|
|
82
82
|
</div>
|
|
83
83
|
) : (
|
|
@@ -150,7 +150,7 @@ export function GRPCRouteRenderer({ data, onNavigate }: GRPCRouteRendererProps)
|
|
|
150
150
|
{match.method && (
|
|
151
151
|
<>
|
|
152
152
|
{match.method.type && match.method.type !== 'Exact' && (
|
|
153
|
-
<
|
|
153
|
+
<Badge tone="accent1" size="sm">{match.method.type}</Badge>
|
|
154
154
|
)}
|
|
155
155
|
{match.method.service && (
|
|
156
156
|
<span>
|
|
@@ -200,9 +200,9 @@ export function GRPCRouteRenderer({ data, onNavigate }: GRPCRouteRendererProps)
|
|
|
200
200
|
<div className="text-xs text-theme-text-secondary flex flex-wrap items-center gap-1.5">
|
|
201
201
|
<Filter className="w-3 h-3 text-theme-text-tertiary" />
|
|
202
202
|
{filters.map((filter: any, filterIdx: number) => (
|
|
203
|
-
<
|
|
203
|
+
<Badge key={filterIdx} tone="accent1" size="sm">
|
|
204
204
|
{filter.type}
|
|
205
|
-
</
|
|
205
|
+
</Badge>
|
|
206
206
|
))}
|
|
207
207
|
</div>
|
|
208
208
|
</div>
|
|
@@ -231,20 +231,14 @@ export function GRPCRouteRenderer({ data, onNavigate }: GRPCRouteRendererProps)
|
|
|
231
231
|
</div>
|
|
232
232
|
<div className="flex flex-wrap gap-2">
|
|
233
233
|
{accepted && (
|
|
234
|
-
<
|
|
235
|
-
'badge',
|
|
236
|
-
accepted.status === 'True' ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'
|
|
237
|
-
)}>
|
|
234
|
+
<Badge severity={accepted.status === 'True' ? 'success' : 'error'}>
|
|
238
235
|
{accepted.status === 'True' ? 'Accepted' : 'Not Accepted'}
|
|
239
|
-
</
|
|
236
|
+
</Badge>
|
|
240
237
|
)}
|
|
241
238
|
{resolved && (
|
|
242
|
-
<
|
|
243
|
-
'badge',
|
|
244
|
-
resolved.status === 'True' ? 'bg-green-500/20 text-green-400' : 'bg-yellow-500/20 text-yellow-400'
|
|
245
|
-
)}>
|
|
239
|
+
<Badge severity={resolved.status === 'True' ? 'success' : 'warning'}>
|
|
246
240
|
{resolved.status === 'True' ? 'Refs Resolved' : 'Unresolved Refs'}
|
|
247
|
-
</
|
|
241
|
+
</Badge>
|
|
248
242
|
)}
|
|
249
243
|
</div>
|
|
250
244
|
{accepted?.message && accepted.status === 'False' && (
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { Cpu } from 'lucide-react'
|
|
2
|
-
import { clsx } from 'clsx'
|
|
3
2
|
import { Section, PropertyList, Property, ConditionsSection, AlertBanner } from '../../ui/drawer-components'
|
|
4
|
-
import {
|
|
3
|
+
import { Badge } from '../../ui/Badge'
|
|
5
4
|
|
|
6
5
|
interface GatewayClassRendererProps {
|
|
7
6
|
data: any
|
|
@@ -35,16 +34,9 @@ export function GatewayClassRenderer({ data }: GatewayClassRendererProps) {
|
|
|
35
34
|
<Property
|
|
36
35
|
label="Accepted"
|
|
37
36
|
value={
|
|
38
|
-
<
|
|
39
|
-
'badge',
|
|
40
|
-
isAccepted
|
|
41
|
-
? 'bg-green-500/20 text-green-400'
|
|
42
|
-
: isNotAccepted
|
|
43
|
-
? 'bg-red-500/20 text-red-400'
|
|
44
|
-
: BADGE_INACTIVE
|
|
45
|
-
)}>
|
|
37
|
+
<Badge severity={isAccepted ? 'success' : isNotAccepted ? 'error' : 'neutral'}>
|
|
46
38
|
{isAccepted ? 'True' : isNotAccepted ? 'False' : 'Unknown'}
|
|
47
|
-
</
|
|
39
|
+
</Badge>
|
|
48
40
|
}
|
|
49
41
|
/>
|
|
50
42
|
</PropertyList>
|
|
@@ -2,14 +2,7 @@ import { Globe, Radio, Lock } from 'lucide-react'
|
|
|
2
2
|
import { clsx } from 'clsx'
|
|
3
3
|
import { Section, PropertyList, Property, ConditionsSection, AlertBanner, ResourceLink } from '../../ui/drawer-components'
|
|
4
4
|
import { BADGE_INACTIVE } from '../../../utils/badge-colors'
|
|
5
|
-
|
|
6
|
-
const protocolColors: Record<string, string> = {
|
|
7
|
-
HTTP: 'bg-blue-500/20 text-blue-400',
|
|
8
|
-
HTTPS: 'bg-green-500/20 text-green-400',
|
|
9
|
-
TLS: 'bg-green-500/20 text-green-400',
|
|
10
|
-
TCP: 'bg-orange-500/20 text-orange-400',
|
|
11
|
-
UDP: 'bg-purple-500/20 text-purple-400',
|
|
12
|
-
}
|
|
5
|
+
import { Badge } from '../../ui/Badge'
|
|
13
6
|
|
|
14
7
|
interface GatewayRendererProps {
|
|
15
8
|
data: any
|
|
@@ -68,31 +61,17 @@ export function GatewayRenderer({ data, onNavigate }: GatewayRendererProps) {
|
|
|
68
61
|
<Property
|
|
69
62
|
label="Accepted"
|
|
70
63
|
value={
|
|
71
|
-
<
|
|
72
|
-
'badge',
|
|
73
|
-
isAccepted
|
|
74
|
-
? 'bg-green-500/20 text-green-400'
|
|
75
|
-
: isNotAccepted
|
|
76
|
-
? 'bg-red-500/20 text-red-400'
|
|
77
|
-
: BADGE_INACTIVE
|
|
78
|
-
)}>
|
|
64
|
+
<Badge severity={isAccepted ? 'success' : isNotAccepted ? 'error' : 'neutral'}>
|
|
79
65
|
{isAccepted ? 'True' : isNotAccepted ? 'False' : 'Unknown'}
|
|
80
|
-
</
|
|
66
|
+
</Badge>
|
|
81
67
|
}
|
|
82
68
|
/>
|
|
83
69
|
<Property
|
|
84
70
|
label="Programmed"
|
|
85
71
|
value={
|
|
86
|
-
<
|
|
87
|
-
'badge',
|
|
88
|
-
isProgrammed
|
|
89
|
-
? 'bg-green-500/20 text-green-400'
|
|
90
|
-
: isNotProgrammed
|
|
91
|
-
? 'bg-red-500/20 text-red-400'
|
|
92
|
-
: BADGE_INACTIVE
|
|
93
|
-
)}>
|
|
72
|
+
<Badge severity={isProgrammed ? 'success' : isNotProgrammed ? 'error' : 'neutral'}>
|
|
94
73
|
{isProgrammed ? 'True' : isNotProgrammed ? 'False' : 'Unknown'}
|
|
95
|
-
</
|
|
74
|
+
</Badge>
|
|
96
75
|
}
|
|
97
76
|
/>
|
|
98
77
|
</PropertyList>
|
|
@@ -133,23 +112,20 @@ export function GatewayRenderer({ data, onNavigate }: GatewayRendererProps) {
|
|
|
133
112
|
<div className="flex items-center gap-2">
|
|
134
113
|
{isHTTPS && <Lock className="w-3.5 h-3.5 text-green-400" />}
|
|
135
114
|
<span className="text-sm font-medium text-theme-text-primary">{listener.name}</span>
|
|
136
|
-
<
|
|
137
|
-
'px-1.5 py-0.5 rounded text-[10px] font-medium',
|
|
138
|
-
protocolColors[listener.protocol] || BADGE_INACTIVE
|
|
139
|
-
)}>
|
|
115
|
+
<Badge protocol={listener.protocol} size="sm">
|
|
140
116
|
{listener.protocol}:{listener.port}
|
|
141
|
-
</
|
|
117
|
+
</Badge>
|
|
142
118
|
</div>
|
|
143
119
|
<div className="flex items-center gap-1">
|
|
144
120
|
{isConflicted && (
|
|
145
|
-
<
|
|
121
|
+
<Badge severity="error" size="sm">
|
|
146
122
|
Conflicted
|
|
147
|
-
</
|
|
123
|
+
</Badge>
|
|
148
124
|
)}
|
|
149
125
|
{hasUnresolvedRefs && (
|
|
150
|
-
<
|
|
126
|
+
<Badge severity="warning" size="sm">
|
|
151
127
|
Unresolved Refs
|
|
152
|
-
</
|
|
128
|
+
</Badge>
|
|
153
129
|
)}
|
|
154
130
|
{listenerAccepted && (
|
|
155
131
|
<span className={clsx(
|