@skyhook-io/k8s-ui 1.8.6 → 1.8.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 +3 -3
- package/src/components/applications/ApplicationsList.tsx +5 -2
- package/src/components/applications/ApplicationsView.tsx +6 -1
- 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/cluster-switcher/ClusterSwitcher.tsx +27 -9
- package/src/components/gitops/GitOpsDetailLayout.tsx +3 -3
- package/src/components/gitops/GitOpsStatusBadge.tsx +9 -3
- package/src/components/gitops/GitOpsTableView.tsx +49 -46
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +12 -5
- package/src/components/issues/IssuesView.tsx +49 -40
- package/src/components/issues/ResourceIssuesSection.tsx +145 -0
- package/src/components/issues/diagnostic.ts +86 -0
- package/src/components/issues/index.ts +2 -1
- package/src/components/issues/issues.test.ts +21 -0
- package/src/components/issues/severity.ts +10 -9
- package/src/components/issues/types.ts +23 -0
- package/src/components/namespace-switcher/NamespacePicker.tsx +381 -0
- package/src/components/namespace-switcher/index.ts +6 -0
- package/src/components/resources/ResourcesView.tsx +58 -83
- package/src/components/resources/cron-to-human.test.ts +41 -0
- 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/JobRenderer.tsx +6 -2
- package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +2 -2
- 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/WorkloadRenderer.tsx +6 -3
- 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-keda.ts +12 -8
- package/src/components/resources/resource-utils.ts +34 -14
- package/src/components/scope-pill/ScopePill.tsx +35 -0
- package/src/components/scope-pill/index.ts +2 -0
- package/src/components/timeline/TimelineList.tsx +27 -1
- 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 +90 -14
- package/src/components/topology/layout.ts +11 -5
- package/src/components/ui/FreshnessControl.tsx +153 -0
- package/src/components/ui/PaneLoader.tsx +24 -6
- package/src/components/ui/SortableTh.tsx +16 -10
- package/src/components/ui/Toast.tsx +1 -1
- package/src/components/ui/drawer-components.test.tsx +35 -0
- package/src/components/ui/drawer-components.tsx +13 -1
- package/src/components/ui/index.ts +2 -0
- package/src/components/workload/ResourceDetailDrawer.tsx +215 -32
- package/src/components/workload/WorkloadView.tsx +61 -13
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useKeyboardShortcuts.tsx +23 -2
- package/src/hooks/useRefreshAnimation.ts +15 -2
- package/src/index.ts +8 -0
- package/src/types/core.ts +142 -3
- package/src/types/gitops-insights.ts +4 -0
- package/src/utils/animation.ts +10 -0
- package/src/utils/applications.test.ts +55 -1
- package/src/utils/applications.ts +28 -7
- package/src/utils/badge-colors.ts +7 -0
- package/src/utils/format-freshness.test.ts +34 -0
- package/src/utils/format.ts +32 -0
- package/src/utils/resource-hierarchy.test.ts +51 -0
- package/src/utils/resource-hierarchy.ts +7 -4
|
@@ -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
|
+
})
|
|
@@ -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
|
+
})
|
|
@@ -25,8 +25,12 @@ function getJobProblems(data: any): string[] {
|
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
// Check for pod failures without terminal condition yet
|
|
29
|
-
|
|
28
|
+
// Check for pod failures without terminal condition yet. A Job that already
|
|
29
|
+
// completed successfully (Complete condition) keeps its earlier failed pod
|
|
30
|
+
// attempts in status.failed — those are retries, not a problem — so don't flag
|
|
31
|
+
// them, or the drawer would read red while the table badge is calm neutral.
|
|
32
|
+
const completeCondition = conditions.find((c: any) => c.type === 'Complete' && c.status === 'True')
|
|
33
|
+
if (!failedCondition && !completeCondition && status.failed > 0) {
|
|
30
34
|
const remaining = (spec.backoffLimit ?? 6) - status.failed
|
|
31
35
|
if (remaining > 0) {
|
|
32
36
|
problems.push(`${status.failed} pod(s) failed — ${remaining} retries remaining`)
|
|
@@ -62,9 +62,9 @@ export function KedaScaledObjectRenderer({ data, onNavigate }: KedaScaledObjectR
|
|
|
62
62
|
)}
|
|
63
63
|
{isPaused && (
|
|
64
64
|
<AlertBanner
|
|
65
|
-
variant="
|
|
65
|
+
variant="info"
|
|
66
66
|
title="Scaling Paused"
|
|
67
|
-
message="Autoscaling is paused via annotation."
|
|
67
|
+
message="Autoscaling is paused via annotation. This is intentional — resume by removing the paused annotation."
|
|
68
68
|
/>
|
|
69
69
|
)}
|
|
70
70
|
|
|
@@ -26,16 +26,12 @@ function formatStorage(value: string | undefined): string {
|
|
|
26
26
|
return formatMemory(value)
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
// Extract problems from node status
|
|
29
|
+
// Extract genuine problems from node status. Cordoned (unschedulable) is
|
|
30
|
+
// deliberately NOT included here — it's an intentional operator action
|
|
31
|
+
// (cordon/drain), surfaced separately as a calm advisory, not a red error.
|
|
30
32
|
function getNodeProblems(data: any): string[] {
|
|
31
33
|
const problems: string[] = []
|
|
32
34
|
const conditions = data.status?.conditions || []
|
|
33
|
-
const spec = data.spec || {}
|
|
34
|
-
|
|
35
|
-
// Check if unschedulable
|
|
36
|
-
if (spec.unschedulable) {
|
|
37
|
-
problems.push('Node is cordoned (unschedulable)')
|
|
38
|
-
}
|
|
39
35
|
|
|
40
36
|
for (const cond of conditions) {
|
|
41
37
|
// NotReady is a problem when status is not True
|
|
@@ -77,6 +73,7 @@ export function NodeRenderer({ data, relationships, onViewPods, metrics, metrics
|
|
|
77
73
|
// Check for problems
|
|
78
74
|
const problems = getNodeProblems(data)
|
|
79
75
|
const hasProblems = problems.length > 0
|
|
76
|
+
const isCordoned = !!spec.unschedulable
|
|
80
77
|
|
|
81
78
|
// Extract platform info from labels
|
|
82
79
|
const instanceType = labels['node.kubernetes.io/instance-type']
|
|
@@ -88,11 +85,23 @@ export function NodeRenderer({ data, relationships, onViewPods, metrics, metrics
|
|
|
88
85
|
|
|
89
86
|
return (
|
|
90
87
|
<>
|
|
91
|
-
{/* Problems alert - shown at top when there are issues */}
|
|
88
|
+
{/* Problems alert - shown at top when there are genuine issues */}
|
|
92
89
|
{hasProblems && (
|
|
93
90
|
<AlertBanner variant="error" title="Issues Detected" items={problems} />
|
|
94
91
|
)}
|
|
95
92
|
|
|
93
|
+
{/* Cordoned is intentional but consequential — it removes scheduling
|
|
94
|
+
capacity and a forgotten cordon strands a node. So it's a warning (amber),
|
|
95
|
+
matching the node table badge + the Cordoned audit check — NOT the calm
|
|
96
|
+
sky of a no-op intentional state (suspended/idle), and not a red error. */}
|
|
97
|
+
{isCordoned && (
|
|
98
|
+
<AlertBanner
|
|
99
|
+
variant="warning"
|
|
100
|
+
title="Cordoned (unschedulable)"
|
|
101
|
+
message="New pods won't be scheduled here. Uncordon to resume scheduling."
|
|
102
|
+
/>
|
|
103
|
+
)}
|
|
104
|
+
|
|
96
105
|
{/* Node Info */}
|
|
97
106
|
<Section title="Node Info" icon={Server}>
|
|
98
107
|
<PropertyList>
|
|
@@ -28,10 +28,10 @@ export function PVCRenderer({ data, onNavigate, extraSections }: PVCRendererProp
|
|
|
28
28
|
const annotations = data.metadata?.annotations || {}
|
|
29
29
|
const phase = status.phase
|
|
30
30
|
|
|
31
|
-
//
|
|
31
|
+
// Lost is a genuine failure (bound volume disappeared). Pending is a normal
|
|
32
|
+
// lifecycle state (provisioning / WaitForFirstConsumer), surfaced calmly below.
|
|
32
33
|
const isLost = phase === 'Lost'
|
|
33
34
|
const isPending = phase === 'Pending'
|
|
34
|
-
const hasProblems = isLost || isPending
|
|
35
35
|
|
|
36
36
|
// Provisioner info from annotations
|
|
37
37
|
const provisioner = annotations['volume.kubernetes.io/storage-provisioner']
|
|
@@ -42,7 +42,7 @@ export function PVCRenderer({ data, onNavigate, extraSections }: PVCRendererProp
|
|
|
42
42
|
return (
|
|
43
43
|
<>
|
|
44
44
|
{/* Problem alerts */}
|
|
45
|
-
{
|
|
45
|
+
{isLost && (
|
|
46
46
|
<AlertBanner
|
|
47
47
|
variant="error"
|
|
48
48
|
title="Issues Detected"
|
|
@@ -50,11 +50,11 @@ export function PVCRenderer({ data, onNavigate, extraSections }: PVCRendererProp
|
|
|
50
50
|
/>
|
|
51
51
|
)}
|
|
52
52
|
|
|
53
|
-
{
|
|
53
|
+
{isPending && (
|
|
54
54
|
<AlertBanner
|
|
55
|
-
variant="
|
|
56
|
-
title="
|
|
57
|
-
message="
|
|
55
|
+
variant="info"
|
|
56
|
+
title="Pending — awaiting binding"
|
|
57
|
+
message="Not yet bound to a volume. This is normal while provisioning, and expected indefinitely for a WaitForFirstConsumer StorageClass until a Pod that mounts this claim is scheduled."
|
|
58
58
|
/>
|
|
59
59
|
)}
|
|
60
60
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useState, type ReactNode, type JSX } from 'react'
|
|
2
2
|
import { Server, HardDrive, Terminal as TerminalIcon, FileText, Activity, CirclePlay, FolderOpen, List, Eye, EyeOff, Shield } from 'lucide-react'
|
|
3
3
|
import { clsx } from 'clsx'
|
|
4
|
-
import { Section, PropertyList, Property, ConditionsSection, CopyHandler, AlertBanner, ResourceLink } from '../../ui/drawer-components'
|
|
4
|
+
import { Section, PropertyList, Property, ConditionsSection, CopyHandler, AlertBanner, ResourceLink, useOperationalIssuesShown } from '../../ui/drawer-components'
|
|
5
5
|
import { formatResources, formatDuration, getPodProblems, getPodPhaseDisplay, healthColors, SEVERITY_DOT_COLOR, getDefaultContainerName } from '../resource-utils'
|
|
6
6
|
import { getResourceStatusColor, SEVERITY_BADGE_BORDERED } from '../../../utils/badge-colors'
|
|
7
7
|
import {
|
|
@@ -274,9 +274,12 @@ export function PodRenderer({
|
|
|
274
274
|
const podName = data.metadata?.name
|
|
275
275
|
const isRunning = data.status?.phase === 'Running'
|
|
276
276
|
|
|
277
|
-
// Check for problems
|
|
277
|
+
// Check for problems. Suppressed when the detail already shows the dedicated
|
|
278
|
+
// Operational Issues section (the Issues pipeline covers the same pod failures,
|
|
279
|
+
// richer) — avoids showing the same crashloop twice.
|
|
280
|
+
const operationalIssuesShown = useOperationalIssuesShown()
|
|
278
281
|
const podProblems = getPodProblems(data)
|
|
279
|
-
const hasProblems = podProblems.length > 0
|
|
282
|
+
const hasProblems = podProblems.length > 0 && !operationalIssuesShown
|
|
280
283
|
|
|
281
284
|
// Image filesystem modal state
|
|
282
285
|
const [selectedImage, setSelectedImage] = useState<string | null>(null)
|
|
@@ -548,6 +551,10 @@ export function PodRenderer({
|
|
|
548
551
|
const lastTermination = status?.lastState?.terminated
|
|
549
552
|
const currentWaiting = status?.state?.waiting
|
|
550
553
|
const currentTerminated = status?.state?.terminated
|
|
554
|
+
// A container that exited 0 (a completed Job pod) is a success, not a
|
|
555
|
+
// failure — tone its badges/text sky, not red, so the drawer agrees
|
|
556
|
+
// with the calm "Completed" table badge instead of screaming red.
|
|
557
|
+
const terminatedOk = currentTerminated?.exitCode === 0
|
|
551
558
|
|
|
552
559
|
return (
|
|
553
560
|
<div key={container.name} className="card-inner-lg">
|
|
@@ -586,14 +593,17 @@ export function PodRenderer({
|
|
|
586
593
|
)}
|
|
587
594
|
<span className={clsx(
|
|
588
595
|
'badge',
|
|
589
|
-
isReady ? SEVERITY_BADGE_BORDERED.success :
|
|
596
|
+
isReady ? SEVERITY_BADGE_BORDERED.success :
|
|
597
|
+
terminatedOk ? SEVERITY_BADGE_BORDERED.info :
|
|
598
|
+
SEVERITY_BADGE_BORDERED.error
|
|
590
599
|
)}>
|
|
591
|
-
{isReady ? 'Ready' : 'Not Ready'}
|
|
600
|
+
{isReady ? 'Ready' : terminatedOk ? 'Completed' : 'Not Ready'}
|
|
592
601
|
</span>
|
|
593
602
|
<span className={clsx(
|
|
594
603
|
'badge',
|
|
595
604
|
stateKey === 'running' ? SEVERITY_BADGE_BORDERED.success :
|
|
596
605
|
stateKey === 'waiting' ? SEVERITY_BADGE_BORDERED.warning :
|
|
606
|
+
terminatedOk ? SEVERITY_BADGE_BORDERED.info :
|
|
597
607
|
SEVERITY_BADGE_BORDERED.error
|
|
598
608
|
)}>
|
|
599
609
|
{stateKey}
|
|
@@ -621,9 +631,10 @@ export function PodRenderer({
|
|
|
621
631
|
)}
|
|
622
632
|
</div>
|
|
623
633
|
)}
|
|
624
|
-
{/* Show current terminated reason
|
|
634
|
+
{/* Show current terminated reason — sky for a clean exit-0
|
|
635
|
+
completion, red only for a genuine failure. */}
|
|
625
636
|
{currentTerminated?.reason && (
|
|
626
|
-
<div className=
|
|
637
|
+
<div className={clsx('flex items-center gap-1', terminatedOk ? 'text-sky-500 dark:text-sky-400' : 'text-red-400')}>
|
|
627
638
|
<span className="font-medium">Terminated: {currentTerminated.reason}</span>
|
|
628
639
|
{currentTerminated.exitCode !== undefined && currentTerminated.exitCode !== 0 && (
|
|
629
640
|
<span className="text-theme-text-tertiary">(exit code {currentTerminated.exitCode})</span>
|
|
@@ -795,8 +806,16 @@ export function PodRenderer({
|
|
|
795
806
|
</Section>
|
|
796
807
|
)}
|
|
797
808
|
|
|
798
|
-
{/* Conditions
|
|
799
|
-
|
|
809
|
+
{/* Conditions. A completed pod's Ready/ContainersReady flip to False with
|
|
810
|
+
reason "PodCompleted" — that's expected for a finished pod, not a failure,
|
|
811
|
+
so tone it neutral (gray) instead of red. Gated on the PodCompleted reason
|
|
812
|
+
so a genuinely not-ready pod (any other reason) still reads red. */}
|
|
813
|
+
<ConditionsSection
|
|
814
|
+
conditions={data.status?.conditions}
|
|
815
|
+
getConditionTone={(cond) =>
|
|
816
|
+
cond?.status === 'False' && cond?.reason === 'PodCompleted' ? 'unknown' : undefined
|
|
817
|
+
}
|
|
818
|
+
/>
|
|
800
819
|
|
|
801
820
|
{/* Permissions (via ServiceAccount) — placed below the diagnostic-
|
|
802
821
|
* signal sections (status, containers, resource usage, conditions)
|
|
@@ -10,7 +10,11 @@ interface ServiceRendererProps {
|
|
|
10
10
|
endpointSlices?: any[]
|
|
11
11
|
endpointSlicesLoading?: boolean
|
|
12
12
|
onNavigate?: (ref: ResourceRef) => void
|
|
13
|
-
renderPortAction?: (props: { namespace: string; serviceName: string; port: number; protocol: string }) => ReactNode
|
|
13
|
+
renderPortAction?: (props: { namespace: string; serviceName: string; port: number; protocol: string; name?: string; appProtocol?: string }) => ReactNode
|
|
14
|
+
/** Optional full-width content rendered inside a port's card, below its header
|
|
15
|
+
* (e.g. an inline probe panel). Lets a host attach a port-scoped panel in the
|
|
16
|
+
* drawer flow rather than as a separate overlay. */
|
|
17
|
+
renderPortPanel?: (props: { namespace: string; serviceName: string; port: number; protocol: string; name?: string; appProtocol?: string }) => ReactNode
|
|
14
18
|
}
|
|
15
19
|
|
|
16
20
|
function endpointSliceAddressCount(slice: any): number {
|
|
@@ -28,7 +32,7 @@ function endpointSliceReadyClass(ready: number, total: number): string {
|
|
|
28
32
|
return 'status-unhealthy'
|
|
29
33
|
}
|
|
30
34
|
|
|
31
|
-
export function ServiceRenderer({ data, onCopy, copied, endpointSlices, endpointSlicesLoading, onNavigate, renderPortAction }: ServiceRendererProps) {
|
|
35
|
+
export function ServiceRenderer({ data, onCopy, copied, endpointSlices, endpointSlicesLoading, onNavigate, renderPortAction, renderPortPanel }: ServiceRendererProps) {
|
|
32
36
|
const spec = data.spec || {}
|
|
33
37
|
const ports = spec.ports || []
|
|
34
38
|
const lbIngress = data.status?.loadBalancer?.ingress || []
|
|
@@ -95,24 +99,34 @@ export function ServiceRenderer({ data, onCopy, copied, endpointSlices, endpoint
|
|
|
95
99
|
<div className="space-y-2">
|
|
96
100
|
{ports.map((port: any, i: number) => (
|
|
97
101
|
<div key={`${port.port}-${port.protocol || 'TCP'}`} className="card-inner text-sm">
|
|
98
|
-
<div className="flex items-center justify-between">
|
|
99
|
-
<div className="flex items-
|
|
102
|
+
<div className="flex items-center justify-between gap-2">
|
|
103
|
+
<div className="flex items-baseline gap-x-2 gap-y-0.5 min-w-0 flex-wrap">
|
|
100
104
|
<span className="text-theme-text-primary font-medium">{port.name || `port-${i + 1}`}</span>
|
|
101
105
|
<span className="text-xs text-theme-text-tertiary">{port.protocol || 'TCP'}</span>
|
|
106
|
+
<span className="text-xs text-theme-text-secondary font-mono">
|
|
107
|
+
{port.port}{port.targetPort != null && port.targetPort !== port.port ? ` → ${port.targetPort}` : ''}
|
|
108
|
+
{port.nodePort ? ` (NodePort: ${port.nodePort})` : ''}
|
|
109
|
+
</span>
|
|
102
110
|
</div>
|
|
103
|
-
<div className="flex items-center gap-2">
|
|
111
|
+
<div className="flex items-center gap-2 shrink-0">
|
|
104
112
|
{renderPortAction?.({
|
|
105
113
|
namespace,
|
|
106
114
|
serviceName,
|
|
107
115
|
port: port.port,
|
|
108
116
|
protocol: port.protocol || 'TCP',
|
|
117
|
+
name: port.name,
|
|
118
|
+
appProtocol: port.appProtocol,
|
|
109
119
|
})}
|
|
110
120
|
</div>
|
|
111
121
|
</div>
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
122
|
+
{renderPortPanel?.({
|
|
123
|
+
namespace,
|
|
124
|
+
serviceName,
|
|
125
|
+
port: port.port,
|
|
126
|
+
protocol: port.protocol || 'TCP',
|
|
127
|
+
name: port.name,
|
|
128
|
+
appProtocol: port.appProtocol,
|
|
129
|
+
})}
|
|
116
130
|
</div>
|
|
117
131
|
))}
|
|
118
132
|
</div>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useState, useEffect } from 'react'
|
|
2
2
|
import { Server, ExternalLink, Scale, Minus, Plus, Loader2, Shield } from 'lucide-react'
|
|
3
3
|
import { clsx } from 'clsx'
|
|
4
|
-
import { Section, PropertyList, Property, ConditionsSection, PodTemplateSection, AlertBanner, ResourceLink, ResourceRefBadge } from '../../ui/drawer-components'
|
|
4
|
+
import { Section, PropertyList, Property, ConditionsSection, PodTemplateSection, AlertBanner, ResourceLink, ResourceRefBadge, useOperationalIssuesShown } from '../../ui/drawer-components'
|
|
5
5
|
import { DialogPortal } from '../../ui/DialogPortal'
|
|
6
6
|
import { Tooltip } from '../../ui/Tooltip'
|
|
7
7
|
import { Badge, type BadgeSeverity } from '../../ui/Badge'
|
|
@@ -165,8 +165,11 @@ export function WorkloadRenderer({ kind, data, onNavigate, onViewPods, onScale,
|
|
|
165
165
|
}
|
|
166
166
|
}, [spec.replicas, scaledTo])
|
|
167
167
|
|
|
168
|
-
// Check for problems and progress
|
|
169
|
-
|
|
168
|
+
// Check for problems and progress. Suppressed when the dedicated Operational
|
|
169
|
+
// Issues section is shown — it carries the workload's own issues plus its pods'
|
|
170
|
+
// (richer, with cause/action), so the workload-status problems would duplicate.
|
|
171
|
+
const operationalIssuesShown = useOperationalIssuesShown()
|
|
172
|
+
const problems = operationalIssuesShown ? [] : getWorkloadProblems(status, spec, kind)
|
|
170
173
|
const hasProblems = problems.length > 0
|
|
171
174
|
const progressMessage = getWorkloadProgress(status, spec, kind)
|
|
172
175
|
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { getArgoApplicationStatus } from './resource-utils-argo'
|
|
3
|
+
|
|
4
|
+
describe('getArgoApplicationStatus', () => {
|
|
5
|
+
// A Suspended Argo app is intentionally paused — neutral (sky), matching the
|
|
6
|
+
// backend rollup (mapArgoHealth) + the GitOps badge, so it doesn't read amber
|
|
7
|
+
// on the resource table while reading Idle in Applications.
|
|
8
|
+
it('maps health Suspended to neutral (sky), not degraded', () => {
|
|
9
|
+
const badge = getArgoApplicationStatus({ status: { health: { status: 'Suspended' }, sync: { status: 'Synced' } } })
|
|
10
|
+
expect(badge.level).toBe('neutral')
|
|
11
|
+
expect(badge.text).toBe('Suspended')
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('still maps a healthy synced app to healthy', () => {
|
|
15
|
+
const badge = getArgoApplicationStatus({ status: { health: { status: 'Healthy' }, sync: { status: 'Synced' } } })
|
|
16
|
+
expect(badge.level).toBe('healthy')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('still maps a degraded app to unhealthy', () => {
|
|
20
|
+
const badge = getArgoApplicationStatus({ status: { health: { status: 'Degraded' }, sync: { status: 'Synced' } } })
|
|
21
|
+
expect(badge.level).toBe('unhealthy')
|
|
22
|
+
})
|
|
23
|
+
})
|
|
@@ -22,7 +22,11 @@ export function getArgoApplicationStatus(app: any): StatusBadge {
|
|
|
22
22
|
const annotations = app.metadata?.annotations
|
|
23
23
|
const suspendedByRadar = annotations?.['radarhq.io/suspended-prune'] || annotations?.['skyhook.io/suspended-prune']
|
|
24
24
|
if (health === 'Suspended' || (!hasAutomatedSync && suspendedByRadar)) {
|
|
25
|
-
|
|
25
|
+
// Suspended = an operator deliberately paused this app — intentional, not a
|
|
26
|
+
// degradation. Neutral (sky), matching the backend rollup (mapArgoHealth) so a
|
|
27
|
+
// suspended app reads the same Idle tone in Applications, the resource table,
|
|
28
|
+
// and GitOps instead of amber in some surfaces and sky in others.
|
|
29
|
+
return { text: 'Suspended', color: healthColors.neutral, level: 'neutral' }
|
|
26
30
|
}
|
|
27
31
|
|
|
28
32
|
// Operation in progress
|
|
@@ -29,7 +29,9 @@ export function getScaledObjectStatus(resource: any): StatusBadge {
|
|
|
29
29
|
conditions.some((c: any) => c.type === 'Paused' && c.status === 'True')
|
|
30
30
|
|
|
31
31
|
if (isPaused) {
|
|
32
|
-
|
|
32
|
+
// Paused = operator deliberately froze autoscaling — intentional, sky/neutral
|
|
33
|
+
// (like Idle), not amber.
|
|
34
|
+
return { text: 'Paused', color: healthColors.neutral, level: 'neutral' }
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
// Check Fallback condition
|
|
@@ -128,26 +130,28 @@ export function getScaledJobStatus(resource: any): StatusBadge {
|
|
|
128
130
|
const conditions = resource.status?.conditions || []
|
|
129
131
|
|
|
130
132
|
const readyCond = conditions.find((c: any) => c.type === 'Ready')
|
|
131
|
-
if (readyCond?.status === 'True') {
|
|
132
|
-
return { text: 'Ready', color: healthColors.healthy, level: 'healthy' }
|
|
133
|
-
}
|
|
134
133
|
// A non-operational scaler (Ready=False) is unhealthy and must take precedence
|
|
135
|
-
// over the Idle
|
|
136
|
-
// ScaledJob hides as benign "Idle". Mirrors getScaledObjectStatus.
|
|
134
|
+
// over the Idle branch — otherwise a broken-and-idle ScaledJob hides as benign.
|
|
137
135
|
if (readyCond?.status === 'False') {
|
|
138
136
|
return { text: readyCond.reason || 'NotReady', color: healthColors.unhealthy, level: 'unhealthy' }
|
|
139
137
|
}
|
|
140
138
|
|
|
139
|
+
// Check Active BEFORE falling back to Ready=True: an operational scaler with no
|
|
140
|
+
// jobs running (Active=False) is intentionally idle → sky, not the green of a
|
|
141
|
+
// busy one. (Ready=True first would make Idle unreachable.) Mirrors
|
|
142
|
+
// getScaledObjectStatus.
|
|
141
143
|
const activeCond = conditions.find((c: any) => c.type === 'Active')
|
|
142
144
|
if (activeCond?.status === 'True') {
|
|
143
145
|
return { text: 'Active', color: healthColors.healthy, level: 'healthy' }
|
|
144
146
|
}
|
|
145
147
|
if (activeCond?.status === 'False') {
|
|
146
|
-
// Idle is the normal resting state of a scaler with no triggers firing
|
|
147
|
-
// (like a CronJob waiting for its next run), not a fault.
|
|
148
148
|
return { text: 'Idle', color: healthColors.neutral, level: 'neutral' }
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
if (readyCond?.status === 'True') {
|
|
152
|
+
return { text: 'Ready', color: healthColors.healthy, level: 'healthy' }
|
|
153
|
+
}
|
|
154
|
+
|
|
151
155
|
return { text: 'Unknown', color: healthColors.unknown, level: 'unknown' }
|
|
152
156
|
}
|
|
153
157
|
|
|
@@ -475,20 +475,23 @@ export function getPodProblems(pod: any): PodProblem[] {
|
|
|
475
475
|
problems.push({ severity: 'high', message: 'Evicted', detail: podStatusMessage })
|
|
476
476
|
}
|
|
477
477
|
|
|
478
|
-
// Stuck terminating (zombie pod)
|
|
478
|
+
// Stuck terminating (zombie pod). Use the same threshold as the badge
|
|
479
|
+
// (TERMINATING_STUCK_MINUTES) so the drawer problem and the table badge flip
|
|
480
|
+
// together — firing at 60s while the badge stayed calm to 10m was a mismatch.
|
|
479
481
|
if (pod.metadata?.deletionTimestamp) {
|
|
480
|
-
|
|
481
|
-
const ageSeconds = (Date.now() - deleteTime) / 1000
|
|
482
|
-
if (ageSeconds > 60) {
|
|
482
|
+
if (minutesSince(pod.metadata.deletionTimestamp) >= TERMINATING_STUCK_MINUTES) {
|
|
483
483
|
problems.push({ severity: 'medium', message: 'Stuck Terminating' })
|
|
484
484
|
}
|
|
485
485
|
}
|
|
486
486
|
|
|
487
|
-
// Not ready (Running but containers not ready)
|
|
487
|
+
// Not ready (Running but containers not ready). Use the same containerSettledOk
|
|
488
|
+
// gate as getPodStatus so a completing Job pod (Running, container terminated
|
|
489
|
+
// exit 0, Ready=false) doesn't raise a drawer problem while the table badge
|
|
490
|
+
// stays calm — a settled/completed container is not "Not Ready".
|
|
488
491
|
if (phase === 'Running') {
|
|
489
|
-
const
|
|
492
|
+
const unsettled = containerStatuses.filter((c: any) => !containerSettledOk(c)).length
|
|
490
493
|
const totalContainers = containerStatuses.length
|
|
491
|
-
if (totalContainers > 0 &&
|
|
494
|
+
if (totalContainers > 0 && unsettled > 0) {
|
|
492
495
|
// Only add if we haven't already flagged a more specific issue
|
|
493
496
|
const hasSpecificIssue = problems.some(p =>
|
|
494
497
|
p.message.includes('Probe') || p.message.includes('CrashLoop') || p.message.includes('OOM')
|
|
@@ -646,7 +649,9 @@ export function getWorkloadStatus(resource: any, kind: string): StatusBadge {
|
|
|
646
649
|
const ready = status.numberReady || 0
|
|
647
650
|
const updated = status.updatedNumberScheduled || 0
|
|
648
651
|
|
|
649
|
-
|
|
652
|
+
// 0 desired = the node selector matches no nodes — intentional/idle, not a
|
|
653
|
+
// fault and not "unknown" (matches pkg/health.Workload). Sky.
|
|
654
|
+
if (desired === 0) return { text: '0 nodes', color: healthColors.neutral, level: 'neutral' }
|
|
650
655
|
if (ready === desired && updated === desired) {
|
|
651
656
|
return { text: `${ready}/${desired}`, color: healthColors.healthy, level: 'healthy' }
|
|
652
657
|
}
|
|
@@ -1008,7 +1013,9 @@ export function getJobStatus(job: any): StatusBadge {
|
|
|
1008
1013
|
|
|
1009
1014
|
const completeCond = conditions.find((c: any) => c.type === 'Complete' && c.status === 'True')
|
|
1010
1015
|
if (completeCond) {
|
|
1011
|
-
|
|
1016
|
+
// A completed Job is done by design — neutral/idle (sky), not the green of a
|
|
1017
|
+
// serving workload (matches pkg/health.Workload).
|
|
1018
|
+
return { text: 'Complete', color: healthColors.neutral, level: 'neutral' }
|
|
1012
1019
|
}
|
|
1013
1020
|
|
|
1014
1021
|
if (job.spec?.suspend) {
|
|
@@ -1244,17 +1251,30 @@ export function cronToHuman(cron: string): string {
|
|
|
1244
1251
|
if (minute === '0' && hour !== '*' && dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
|
|
1245
1252
|
return `Daily at ${hour}:00`
|
|
1246
1253
|
}
|
|
1247
|
-
|
|
1254
|
+
// Exclude step-minute ("*/N") here so it falls through to the interval branch
|
|
1255
|
+
// below — otherwise "*/5 * * * *" rendered as "Every hour at :*/5" instead of
|
|
1256
|
+
// "Every 5 minutes". A literal minute like "30" still reads "Every hour at :30".
|
|
1257
|
+
if (minute !== '*' && !minute.startsWith('*/') && hour === '*' && dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
|
|
1248
1258
|
return `Every hour at :${minute.padStart(2, '0')}`
|
|
1249
1259
|
}
|
|
1250
1260
|
if (minute === '*' && hour === '*' && dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
|
|
1251
1261
|
return 'Every minute'
|
|
1252
1262
|
}
|
|
1253
|
-
|
|
1263
|
+
// Only claim a plain "Every N minutes" when nothing else constrains the window;
|
|
1264
|
+
// otherwise "*/5 9 * * *" (only at 09:xx) or "*/5 * * * 1-5" (weekdays only)
|
|
1265
|
+
// would read as an unconstrained interval. Constrained shapes fall through to
|
|
1266
|
+
// the raw cron rather than assert something misleading.
|
|
1267
|
+
if (minute.startsWith('*/') && hour === '*' && dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
|
|
1254
1268
|
const interval = minute.slice(2)
|
|
1255
|
-
return `Every ${interval} minutes`
|
|
1256
|
-
}
|
|
1257
|
-
|
|
1269
|
+
return interval === '1' ? 'Every minute' : `Every ${interval} minutes`
|
|
1270
|
+
}
|
|
1271
|
+
// Weekday phrasing needs a literal hour:minute — a wildcard/step in either field
|
|
1272
|
+
// (e.g. "*/5 * * * 1-5") would render "Weekdays at *:*/5", so let it fall to raw.
|
|
1273
|
+
if (
|
|
1274
|
+
(dayOfWeek === '1-5' || dayOfWeek === 'MON-FRI') &&
|
|
1275
|
+
hour !== '*' && !hour.startsWith('*/') &&
|
|
1276
|
+
minute !== '*' && !minute.startsWith('*/')
|
|
1277
|
+
) {
|
|
1258
1278
|
return `Weekdays at ${hour}:${minute.padStart(2, '0')}`
|
|
1259
1279
|
}
|
|
1260
1280
|
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
export interface ScopePillProps {
|
|
4
|
+
/**
|
|
5
|
+
* The scope segments — typically a cluster switcher followed by a namespace
|
|
6
|
+
* picker, each rendered in its `variant="segment"` form (borderless). They're
|
|
7
|
+
* separated by a divider and read as one "what am I looking at" unit.
|
|
8
|
+
*/
|
|
9
|
+
children: ReactNode
|
|
10
|
+
className?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* ScopePill is the shared bordered shell for the cluster + namespace "scope"
|
|
15
|
+
* control, used by both OSS Radar's header and Radar Hub's cluster top bar so
|
|
16
|
+
* the two stay visually identical. It is purely the container: the segments
|
|
17
|
+
* (ClusterSwitcher / NamespacePicker in segment variant) and their data,
|
|
18
|
+
* view-awareness, and any layout pinning are the host's concern.
|
|
19
|
+
*
|
|
20
|
+
* Deliberately NO `overflow-hidden`: ClusterSwitcher's dropdown renders inline
|
|
21
|
+
* (absolute, not portaled), so clipping this ancestor would hide it. Instead of
|
|
22
|
+
* clipping, the outer corners of the first/last segment's TRIGGER button are
|
|
23
|
+
* rounded (7px = the 8px pill radius minus its 1px border) so each segment's
|
|
24
|
+
* hover/active fill follows the pill's shape instead of poking square corners
|
|
25
|
+
* past it. `>button` targets only the trigger, never the dropdown's buttons.
|
|
26
|
+
*/
|
|
27
|
+
export function ScopePill({ children, className = '' }: ScopePillProps) {
|
|
28
|
+
return (
|
|
29
|
+
<div
|
|
30
|
+
className={`flex items-stretch shrink-0 rounded-lg border border-theme-border bg-theme-surface divide-x divide-theme-border [&>*:first-child>button]:rounded-l-[7px] [&>*:last-child>button]:rounded-r-[7px] ${className}`}
|
|
31
|
+
>
|
|
32
|
+
{children}
|
|
33
|
+
</div>
|
|
34
|
+
)
|
|
35
|
+
}
|