@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,381 @@
|
|
|
1
|
+
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
|
|
2
|
+
import { createPortal } from 'react-dom'
|
|
3
|
+
import { ChevronDown, Globe, Search, AlertTriangle, X } from 'lucide-react'
|
|
4
|
+
import { Tooltip } from '../ui/Tooltip'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Backend-reported namespace scope. Mirrors Radar's `/cluster/namespace-scope`
|
|
8
|
+
* response shape; the host supplies it however it fetches it (OSS via its API
|
|
9
|
+
* hooks, Radar Hub via the per-cluster apiBase).
|
|
10
|
+
*/
|
|
11
|
+
export interface NamespaceScopeView {
|
|
12
|
+
/** Currently-selected namespaces. Empty = cluster-wide ("All namespaces"). */
|
|
13
|
+
actives: string[]
|
|
14
|
+
/** Namespaces the user may pick from. */
|
|
15
|
+
accessibleNamespaces: string[]
|
|
16
|
+
mode?: 'cluster-wide' | 'namespace' | 'restricted' | string
|
|
17
|
+
/** Single-namespace cache-scope control instead of a multi-select filter. */
|
|
18
|
+
cacheScoped?: boolean
|
|
19
|
+
/** Under cacheScoped, whether the user may re-point the watched namespace. */
|
|
20
|
+
namespaceRescope?: boolean
|
|
21
|
+
cacheScopeNamespace?: string
|
|
22
|
+
kubeconfigNamespace?: string
|
|
23
|
+
canClearNamespace?: boolean
|
|
24
|
+
/** false when accessibleNamespaces is a best-effort short list (no list perm). */
|
|
25
|
+
authoritative?: boolean
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface NamespacePickerHandle {
|
|
29
|
+
open: () => void
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface NamespacePickerProps {
|
|
33
|
+
/** null/undefined while loading — the picker renders nothing until it arrives. */
|
|
34
|
+
scope: NamespaceScopeView | null | undefined
|
|
35
|
+
/**
|
|
36
|
+
* Applied when the selection is committed (dropdown close / clear all).
|
|
37
|
+
* The picker only calls this when the selection actually changed and is
|
|
38
|
+
* valid (respects the cacheScoped single-namespace constraint).
|
|
39
|
+
*/
|
|
40
|
+
onApply: (namespaces: string[]) => void
|
|
41
|
+
loading?: boolean
|
|
42
|
+
/** A switch/mutation is in flight — trigger shows "Switching…" and is inert. */
|
|
43
|
+
pending?: boolean
|
|
44
|
+
disabled?: boolean
|
|
45
|
+
disabledTooltip?: string
|
|
46
|
+
className?: string
|
|
47
|
+
/**
|
|
48
|
+
* 'chip' (default) renders a self-contained pill. 'segment' renders a
|
|
49
|
+
* borderless label+value cell for embedding in a shared bordered container
|
|
50
|
+
* (the unified cluster+namespace scope control), with an optional muted
|
|
51
|
+
* {@link label} before the value and a shorter value ("All" vs "All namespaces").
|
|
52
|
+
*/
|
|
53
|
+
variant?: 'chip' | 'segment'
|
|
54
|
+
/** Muted label shown before the value in the 'segment' variant (e.g. "Namespace"). */
|
|
55
|
+
label?: string
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* NamespacePicker is the presentational namespace scope control shared by
|
|
60
|
+
* Radar OSS and Radar Hub (mirrors the ClusterSwitcher pattern — pure UI, data
|
|
61
|
+
* injected via props). It is normally a per-user multi-select view filter. When
|
|
62
|
+
* the scope reports cacheScoped=true, it becomes a single-namespace cache scope
|
|
63
|
+
* control.
|
|
64
|
+
*
|
|
65
|
+
* Three states reflect what the scope reports:
|
|
66
|
+
* - cluster-wide: empty trigger label "All namespaces"; picker lets the user
|
|
67
|
+
* narrow the view.
|
|
68
|
+
* - namespace: label shows the namespace count (or single name); picker
|
|
69
|
+
* offers other accessible namespaces and a clear-all reset.
|
|
70
|
+
* - restricted: user can't list namespaces and isn't pinned; picker
|
|
71
|
+
* surfaces only the kubeconfig context's namespace + any saved picks.
|
|
72
|
+
*
|
|
73
|
+
* Selection model: the dropdown keeps a draft Set<string>; toggling rows
|
|
74
|
+
* mutates the draft locally; closing the dropdown applies the draft in a single
|
|
75
|
+
* onApply. "Clear all" applies immediately and closes.
|
|
76
|
+
*/
|
|
77
|
+
export const NamespacePicker = forwardRef<NamespacePickerHandle, NamespacePickerProps>(function NamespacePicker(
|
|
78
|
+
{ scope, onApply, loading = false, pending = false, disabled = false, disabledTooltip, className = '', variant = 'chip', label },
|
|
79
|
+
ref,
|
|
80
|
+
) {
|
|
81
|
+
const [isOpen, setIsOpen] = useState(false)
|
|
82
|
+
const [search, setSearch] = useState('')
|
|
83
|
+
const [pos, setPos] = useState({ top: 0, left: 0, width: 0 })
|
|
84
|
+
const [draft, setDraft] = useState<Set<string>>(() => new Set())
|
|
85
|
+
|
|
86
|
+
const triggerRef = useRef<HTMLButtonElement>(null)
|
|
87
|
+
const dropdownRef = useRef<HTMLDivElement>(null)
|
|
88
|
+
|
|
89
|
+
const scopeActives = useMemo(() => scope?.actives ?? [], [scope?.actives])
|
|
90
|
+
const activesKey = useMemo(() => [...scopeActives].sort().join(','), [scopeActives])
|
|
91
|
+
|
|
92
|
+
// Sync the draft with the server's view whenever it changes (initial load,
|
|
93
|
+
// post-mutation refetch, eviction after RBAC drift).
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
setDraft(new Set(scopeActives))
|
|
96
|
+
}, [activesKey, scopeActives])
|
|
97
|
+
|
|
98
|
+
// Fully disable when the host disables the control (e.g. view-awareness
|
|
99
|
+
// navigating to a cluster-scoped surface): the trigger is inert and open()
|
|
100
|
+
// is blocked, but an already-open dropdown would still commit via Done /
|
|
101
|
+
// outside-click / Clear all — so close it (discarding the uncommitted draft)
|
|
102
|
+
// rather than letting a dead view apply a namespace change.
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
if (disabled && isOpen) {
|
|
105
|
+
setIsOpen(false)
|
|
106
|
+
setSearch('')
|
|
107
|
+
// Discard the uncommitted draft so a later re-open reflects the current
|
|
108
|
+
// server actives, not stale toggles from before the control was disabled.
|
|
109
|
+
setDraft(new Set(scopeActives))
|
|
110
|
+
}
|
|
111
|
+
}, [disabled, isOpen, scopeActives])
|
|
112
|
+
|
|
113
|
+
const items = useMemo(() => {
|
|
114
|
+
if (!scope) return [] as string[]
|
|
115
|
+
return [...(scope.accessibleNamespaces ?? [])].sort((a, b) => a.localeCompare(b))
|
|
116
|
+
}, [scope])
|
|
117
|
+
|
|
118
|
+
const filteredItems = useMemo(() => {
|
|
119
|
+
const q = search.trim().toLowerCase()
|
|
120
|
+
if (!q) return items
|
|
121
|
+
return items.filter(n => n.toLowerCase().includes(q))
|
|
122
|
+
}, [items, search])
|
|
123
|
+
|
|
124
|
+
const applySelection = useCallback((next: Set<string>) => {
|
|
125
|
+
if (!scope) return
|
|
126
|
+
const nextArr = Array.from(next).sort()
|
|
127
|
+
if (scope.cacheScoped && nextArr.length !== 1) return
|
|
128
|
+
if (nextArr.join(',') === activesKey) return
|
|
129
|
+
onApply(nextArr)
|
|
130
|
+
}, [activesKey, scope, onApply])
|
|
131
|
+
|
|
132
|
+
const closeAndApply = useCallback(() => {
|
|
133
|
+
setIsOpen(false)
|
|
134
|
+
setSearch('')
|
|
135
|
+
applySelection(draft)
|
|
136
|
+
}, [applySelection, draft])
|
|
137
|
+
|
|
138
|
+
useImperativeHandle(ref, () => ({
|
|
139
|
+
open: () => {
|
|
140
|
+
if (disabled || loading || pending) return
|
|
141
|
+
setIsOpen(true)
|
|
142
|
+
},
|
|
143
|
+
}), [disabled, loading, pending])
|
|
144
|
+
|
|
145
|
+
useEffect(() => {
|
|
146
|
+
if (!isOpen) return
|
|
147
|
+
const trigger = triggerRef.current
|
|
148
|
+
if (!trigger) return
|
|
149
|
+
const r = trigger.getBoundingClientRect()
|
|
150
|
+
setPos({ top: r.bottom + 4, left: r.left, width: Math.max(r.width, 240) })
|
|
151
|
+
}, [isOpen])
|
|
152
|
+
|
|
153
|
+
useEffect(() => {
|
|
154
|
+
if (!isOpen) return
|
|
155
|
+
function onClick(e: MouseEvent) {
|
|
156
|
+
if (
|
|
157
|
+
!dropdownRef.current?.contains(e.target as Node) &&
|
|
158
|
+
!triggerRef.current?.contains(e.target as Node)
|
|
159
|
+
) {
|
|
160
|
+
closeAndApply()
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
function onKey(e: KeyboardEvent) {
|
|
164
|
+
if (e.key === 'Escape') closeAndApply()
|
|
165
|
+
}
|
|
166
|
+
document.addEventListener('mousedown', onClick)
|
|
167
|
+
document.addEventListener('keydown', onKey)
|
|
168
|
+
return () => {
|
|
169
|
+
document.removeEventListener('mousedown', onClick)
|
|
170
|
+
document.removeEventListener('keydown', onKey)
|
|
171
|
+
}
|
|
172
|
+
}, [isOpen, closeAndApply])
|
|
173
|
+
|
|
174
|
+
if (!scope) return null
|
|
175
|
+
|
|
176
|
+
const toggle = (ns: string) => {
|
|
177
|
+
if (scope.cacheScoped) {
|
|
178
|
+
setDraft(new Set([ns]))
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
const next = new Set(draft)
|
|
182
|
+
if (next.has(ns)) next.delete(ns)
|
|
183
|
+
else next.add(ns)
|
|
184
|
+
setDraft(next)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const clearAll = () => {
|
|
188
|
+
if (scope.cacheScoped) return
|
|
189
|
+
setDraft(new Set())
|
|
190
|
+
setIsOpen(false)
|
|
191
|
+
setSearch('')
|
|
192
|
+
applySelection(new Set())
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const selectAllVisible = () => {
|
|
196
|
+
const next = new Set(draft)
|
|
197
|
+
for (const ns of filteredItems) next.add(ns)
|
|
198
|
+
setDraft(next)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const clearVisible = () => {
|
|
202
|
+
const next = new Set(draft)
|
|
203
|
+
for (const ns of filteredItems) next.delete(ns)
|
|
204
|
+
setDraft(next)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const activeCount = scopeActives.length
|
|
208
|
+
const triggerLabel =
|
|
209
|
+
activeCount === 0 ? 'All namespaces' : activeCount === 1 ? scopeActives[0] : `${activeCount} namespaces`
|
|
210
|
+
const isClusterWide = activeCount === 0
|
|
211
|
+
const restrictedHint = scope.mode === 'restricted'
|
|
212
|
+
const cacheScopeLocked = scope.cacheScoped && !scope.namespaceRescope
|
|
213
|
+
const isDisabled = disabled || loading || pending || cacheScopeLocked
|
|
214
|
+
const canClearAll = scope.canClearNamespace || activeCount === 0
|
|
215
|
+
const tooltipContent = disabled && disabledTooltip
|
|
216
|
+
? disabledTooltip
|
|
217
|
+
: scope.cacheScoped
|
|
218
|
+
? scope.namespaceRescope
|
|
219
|
+
? `Radar is watching only ${scope.cacheScopeNamespace || triggerLabel} to stay fast on large clusters. Pick another namespace to re-point it (takes a moment; closes open terminals).`
|
|
220
|
+
: `Radar is watching only ${scope.cacheScopeNamespace || triggerLabel} on this cluster.`
|
|
221
|
+
: restrictedHint
|
|
222
|
+
? 'Limited namespace visibility — only namespaces granted by your RBAC are shown.'
|
|
223
|
+
: isClusterWide
|
|
224
|
+
? 'Currently viewing all namespaces. Click to narrow the view.'
|
|
225
|
+
: activeCount === 1
|
|
226
|
+
? `View is filtered to namespace ${scopeActives[0]}. Click to switch or reset.`
|
|
227
|
+
: `View is filtered to ${activeCount} namespaces. Click to adjust or reset.`
|
|
228
|
+
|
|
229
|
+
// Counts used to label the bulk-action buttons; computed against the visible
|
|
230
|
+
// (filtered) set so the labels match what the action will affect.
|
|
231
|
+
const visibleSelectedCount = filteredItems.reduce((n, ns) => n + (draft.has(ns) ? 1 : 0), 0)
|
|
232
|
+
const allVisibleSelected = filteredItems.length > 0 && visibleSelectedCount === filteredItems.length
|
|
233
|
+
|
|
234
|
+
return (
|
|
235
|
+
<>
|
|
236
|
+
<Tooltip
|
|
237
|
+
content={tooltipContent}
|
|
238
|
+
delay={300}
|
|
239
|
+
position="bottom"
|
|
240
|
+
>
|
|
241
|
+
<button
|
|
242
|
+
ref={triggerRef}
|
|
243
|
+
onClick={() => !isDisabled && (isOpen ? closeAndApply() : setIsOpen(true))}
|
|
244
|
+
disabled={isDisabled}
|
|
245
|
+
className={
|
|
246
|
+
variant === 'segment'
|
|
247
|
+
? `flex items-center justify-center gap-1.5 px-3 py-1.5 h-full min-w-[110px] max-w-[200px] text-[13px] text-theme-text-primary hover:bg-theme-hover disabled:opacity-60 transition-colors ${className}`
|
|
248
|
+
: `flex items-center gap-1.5 px-2 py-1 rounded text-sm bg-theme-elevated hover:bg-theme-hover text-theme-text-primary disabled:opacity-60 transition-colors ${className}`
|
|
249
|
+
}
|
|
250
|
+
aria-label="Switch active namespaces"
|
|
251
|
+
>
|
|
252
|
+
{label && (
|
|
253
|
+
<span className="shrink-0 font-normal text-theme-text-tertiary">{label}</span>
|
|
254
|
+
)}
|
|
255
|
+
{isClusterWide ? (
|
|
256
|
+
<Globe className="w-3.5 h-3.5 shrink-0 text-theme-text-tertiary" />
|
|
257
|
+
) : restrictedHint ? (
|
|
258
|
+
<AlertTriangle className="w-3.5 h-3.5 shrink-0 text-theme-text-tertiary" />
|
|
259
|
+
) : null}
|
|
260
|
+
<span className={`font-medium truncate ${variant === 'segment' ? 'min-w-0' : 'max-w-[180px]'}`}>
|
|
261
|
+
{pending ? 'Switching…' : triggerLabel}
|
|
262
|
+
</span>
|
|
263
|
+
<ChevronDown className="w-3 h-3 shrink-0 opacity-60" />
|
|
264
|
+
</button>
|
|
265
|
+
</Tooltip>
|
|
266
|
+
|
|
267
|
+
{isOpen &&
|
|
268
|
+
createPortal(
|
|
269
|
+
<div
|
|
270
|
+
ref={dropdownRef}
|
|
271
|
+
style={{ position: 'fixed', top: pos.top, left: pos.left, minWidth: pos.width, zIndex: 100 }}
|
|
272
|
+
className="bg-theme-surface border border-theme-border rounded-md shadow-theme-lg overflow-hidden"
|
|
273
|
+
>
|
|
274
|
+
{items.length > 6 && (
|
|
275
|
+
<div className="flex items-center gap-2 px-2 py-1.5 border-b border-theme-border">
|
|
276
|
+
<Search className="w-3.5 h-3.5 text-theme-text-tertiary" />
|
|
277
|
+
<input
|
|
278
|
+
autoFocus
|
|
279
|
+
value={search}
|
|
280
|
+
onChange={e => setSearch(e.target.value)}
|
|
281
|
+
placeholder="Filter namespaces"
|
|
282
|
+
className="flex-1 bg-transparent text-sm outline-none text-theme-text-primary placeholder:text-theme-text-tertiary"
|
|
283
|
+
/>
|
|
284
|
+
</div>
|
|
285
|
+
)}
|
|
286
|
+
|
|
287
|
+
{scope.cacheScoped ? (
|
|
288
|
+
<div className="px-3 py-1.5 border-b border-theme-border text-[11px] leading-snug text-theme-text-secondary">
|
|
289
|
+
Radar is watching one namespace to stay fast on large clusters.
|
|
290
|
+
{scope.namespaceRescope
|
|
291
|
+
? ' Pick another to re-point it — takes a moment and closes open terminals.'
|
|
292
|
+
: ' This instance is locked to its startup namespace.'}
|
|
293
|
+
</div>
|
|
294
|
+
) : (
|
|
295
|
+
<div className="flex items-center justify-between px-2 py-1.5 border-b border-theme-border text-xs text-theme-text-secondary">
|
|
296
|
+
<button
|
|
297
|
+
onClick={canClearAll ? clearAll : undefined}
|
|
298
|
+
disabled={!canClearAll || activeCount === 0}
|
|
299
|
+
className="flex items-center gap-1 px-1.5 py-0.5 rounded hover:bg-theme-hover disabled:opacity-50 disabled:hover:bg-transparent"
|
|
300
|
+
aria-label="Clear namespace selection"
|
|
301
|
+
>
|
|
302
|
+
<X className="w-3 h-3" />
|
|
303
|
+
Clear all
|
|
304
|
+
</button>
|
|
305
|
+
<button
|
|
306
|
+
onClick={allVisibleSelected ? clearVisible : selectAllVisible}
|
|
307
|
+
disabled={filteredItems.length === 0}
|
|
308
|
+
className="px-1.5 py-0.5 rounded hover:bg-theme-hover disabled:opacity-50 disabled:hover:bg-transparent"
|
|
309
|
+
>
|
|
310
|
+
{allVisibleSelected
|
|
311
|
+
? `Clear ${filteredItems.length} visible`
|
|
312
|
+
: search.trim()
|
|
313
|
+
? `Select ${filteredItems.length} visible`
|
|
314
|
+
: 'Select all'}
|
|
315
|
+
</button>
|
|
316
|
+
</div>
|
|
317
|
+
)}
|
|
318
|
+
|
|
319
|
+
<ul className="max-h-80 overflow-y-auto py-1">
|
|
320
|
+
{filteredItems.length === 0 && (
|
|
321
|
+
<li className="px-3 py-2 text-xs text-theme-text-tertiary">
|
|
322
|
+
{search ? 'No matches.' : 'No namespaces available.'}
|
|
323
|
+
</li>
|
|
324
|
+
)}
|
|
325
|
+
|
|
326
|
+
{filteredItems.map(ns => {
|
|
327
|
+
const isChecked = draft.has(ns)
|
|
328
|
+
const isContextDefault = ns === scope.kubeconfigNamespace && ns !== ''
|
|
329
|
+
return (
|
|
330
|
+
<li key={ns}>
|
|
331
|
+
<label
|
|
332
|
+
className="w-full flex items-center justify-between px-3 py-1.5 text-sm hover:bg-theme-hover text-left text-theme-text-primary cursor-pointer"
|
|
333
|
+
>
|
|
334
|
+
<span className="flex items-center gap-2 min-w-0">
|
|
335
|
+
<input
|
|
336
|
+
type={scope.cacheScoped ? 'radio' : 'checkbox'}
|
|
337
|
+
name={scope.cacheScoped ? 'namespace-cache-scope' : undefined}
|
|
338
|
+
checked={isChecked}
|
|
339
|
+
onChange={() => toggle(ns)}
|
|
340
|
+
className="shrink-0 accent-current"
|
|
341
|
+
/>
|
|
342
|
+
<span className="truncate">{ns}</span>
|
|
343
|
+
{isContextDefault && (
|
|
344
|
+
<span className="text-[10px] uppercase tracking-wide text-theme-text-tertiary shrink-0">
|
|
345
|
+
kubeconfig
|
|
346
|
+
</span>
|
|
347
|
+
)}
|
|
348
|
+
</span>
|
|
349
|
+
</label>
|
|
350
|
+
</li>
|
|
351
|
+
)
|
|
352
|
+
})}
|
|
353
|
+
</ul>
|
|
354
|
+
|
|
355
|
+
<div className="flex items-center justify-between px-3 py-1.5 border-t border-theme-border text-[11px] text-theme-text-tertiary">
|
|
356
|
+
<span>
|
|
357
|
+
{scope.cacheScoped
|
|
358
|
+
? (draft.size === 1 ? Array.from(draft)[0] : 'Select a namespace')
|
|
359
|
+
: draft.size === 0 ? 'All namespaces' : `${draft.size} selected`}
|
|
360
|
+
</span>
|
|
361
|
+
<button
|
|
362
|
+
onClick={closeAndApply}
|
|
363
|
+
className="px-2 py-0.5 rounded bg-theme-elevated hover:bg-theme-hover text-theme-text-primary"
|
|
364
|
+
>
|
|
365
|
+
Done
|
|
366
|
+
</button>
|
|
367
|
+
</div>
|
|
368
|
+
|
|
369
|
+
{!scope.authoritative && (
|
|
370
|
+
<div className="px-3 py-2 border-t border-theme-border text-[11px] status-degraded">
|
|
371
|
+
Limited list — your RBAC doesn’t allow listing all
|
|
372
|
+
namespaces. Other namespaces may be accessible but won’t
|
|
373
|
+
appear here until you switch context.
|
|
374
|
+
</div>
|
|
375
|
+
)}
|
|
376
|
+
</div>,
|
|
377
|
+
document.body,
|
|
378
|
+
)}
|
|
379
|
+
</>
|
|
380
|
+
)
|
|
381
|
+
})
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import React, { useState, useMemo, useEffect, useCallback, useRef, useContext, useId } from 'react'
|
|
2
2
|
import { TableVirtuoso, type TableVirtuosoHandle } from 'react-virtuoso'
|
|
3
|
-
import { useRefreshAnimation } from '../../hooks/useRefreshAnimation'
|
|
4
3
|
import { PaneLoader } from '../ui/PaneLoader'
|
|
5
4
|
import { RestrictedState } from '../ui/RestrictedState'
|
|
6
5
|
import type { TopPodMetrics, TopNodeMetrics } from '../../types'
|
|
@@ -13,7 +12,6 @@ import {
|
|
|
13
12
|
ChevronDown,
|
|
14
13
|
ChevronUp,
|
|
15
14
|
ArrowUpDown,
|
|
16
|
-
Clock,
|
|
17
15
|
ListFilter,
|
|
18
16
|
X,
|
|
19
17
|
Columns3,
|
|
@@ -129,11 +127,13 @@ import {
|
|
|
129
127
|
podMatchesProblemCategory,
|
|
130
128
|
SEVERITY_DOT_COLOR,
|
|
131
129
|
} from './resource-utils'
|
|
132
|
-
import { SEVERITY_BADGE, EVENT_TYPE_COLORS } from '../../utils/badge-colors'
|
|
130
|
+
import { SEVERITY_BADGE, EVENT_TYPE_COLORS, SEVERITY_TEXT } from '../../utils/badge-colors'
|
|
133
131
|
import { pluralize } from '../../utils/pluralize'
|
|
134
132
|
import { getPodGpuCount, getNodeGpuCount } from '../../utils/extended-resources'
|
|
135
133
|
import { type CustomColumnDef, type CustomColumnSource, customColumnKey, readCustomColumnValue, sanitizeCustomColumnDefs } from '../../utils/custom-columns'
|
|
134
|
+
import { FreshnessControl, type FreshnessConnection } from '../ui/FreshnessControl'
|
|
136
135
|
import { Tooltip } from '../ui/Tooltip'
|
|
136
|
+
import { AuditBadgeTooltip, type AuditBadgeMessage } from '../audit/AuditBadgeTooltip'
|
|
137
137
|
// CRD-specific cell components (extracted)
|
|
138
138
|
import { GitRepositoryCell, OCIRepositoryCell, HelmRepositoryCell, KustomizationCell, FluxHelmReleaseCell, FluxAlertCell } from './renderers/flux-cells'
|
|
139
139
|
import { ArgoApplicationCell, ArgoApplicationSetCell, ArgoAppProjectCell } from './renderers/argo-cells'
|
|
@@ -1800,6 +1800,9 @@ interface ResourcesViewData {
|
|
|
1800
1800
|
onNavigate?: (path: string, options?: { replace?: boolean }) => void
|
|
1801
1801
|
certExpiry?: Record<string, { expired?: boolean; daysLeft: number }>
|
|
1802
1802
|
certExpiryError?: boolean
|
|
1803
|
+
// Cluster Audit findings for the listed kind, keyed by "namespace/name" (the
|
|
1804
|
+
// list shows one kind at a time, so ns/name is unambiguous). Host-injected.
|
|
1805
|
+
auditBadges?: Record<string, { danger: number; warning: number; messages?: AuditBadgeMessage[] }>
|
|
1803
1806
|
onOpenLogs?: (params: { namespace: string; podName: string; containers: string[]; containerName?: string }) => void
|
|
1804
1807
|
onOpenWorkloadLogs?: (params: { namespace: string; workloadKind: string; workloadName: string }) => void
|
|
1805
1808
|
}
|
|
@@ -1847,11 +1850,16 @@ interface ResourcesViewProps {
|
|
|
1847
1850
|
resourceUnavailable?: string[]
|
|
1848
1851
|
// Single query for the currently selected kind's full data
|
|
1849
1852
|
selectedKindQuery?: ResourceQueryResult
|
|
1853
|
+
// Cluster/SSE connection health — the list is SSE-invalidated ("Auto-updating"),
|
|
1854
|
+
// so it must degrade to "Reconnecting…" when the stream drops.
|
|
1855
|
+
connectionState?: FreshnessConnection
|
|
1850
1856
|
largeListGuard?: LargeListGuardState | null
|
|
1851
1857
|
topPodMetrics?: TopPodMetrics[]
|
|
1852
1858
|
topNodeMetrics?: TopNodeMetrics[]
|
|
1853
1859
|
certExpiry?: Record<string, { expired?: boolean; daysLeft: number }>
|
|
1854
1860
|
certExpiryError?: boolean
|
|
1861
|
+
// Cluster Audit findings for the selected kind, keyed by "namespace/name".
|
|
1862
|
+
auditBadges?: Record<string, { danger: number; warning: number; messages?: AuditBadgeMessage[] }>
|
|
1855
1863
|
// Pinned kinds
|
|
1856
1864
|
pinned?: Array<{ name: string; kind: string; group: string }>
|
|
1857
1865
|
togglePin?: (kind: { name: string; kind: string; group: string }) => void
|
|
@@ -2011,51 +2019,6 @@ function getInitialFiltersFromURL() {
|
|
|
2011
2019
|
// Sort state type
|
|
2012
2020
|
type SortDirection = 'asc' | 'desc' | null
|
|
2013
2021
|
|
|
2014
|
-
// Coarse "just now / Xm / Xh / Xd" buckets — finer-grained updates
|
|
2015
|
-
// add motion in the periphery without aiding any user decision.
|
|
2016
|
-
function formatLastUpdatedBucket(elapsedMs: number): string {
|
|
2017
|
-
const elapsedSec = Math.max(0, Math.floor(elapsedMs / 1000))
|
|
2018
|
-
if (elapsedSec < 60) return 'just now'
|
|
2019
|
-
const minutes = Math.floor(elapsedSec / 60)
|
|
2020
|
-
if (minutes < 60) return `${minutes}m`
|
|
2021
|
-
const hours = Math.floor(minutes / 60)
|
|
2022
|
-
if (hours < 24) return `${hours}h`
|
|
2023
|
-
return `${Math.floor(hours / 24)}d`
|
|
2024
|
-
}
|
|
2025
|
-
|
|
2026
|
-
// ms until the displayed bucket would change.
|
|
2027
|
-
function msToNextBucket(elapsedMs: number): number {
|
|
2028
|
-
const elapsed = Math.max(0, elapsedMs)
|
|
2029
|
-
if (elapsed < 60_000) return 60_000 - elapsed
|
|
2030
|
-
if (elapsed < 3_600_000) return 60_000 - (elapsed % 60_000)
|
|
2031
|
-
if (elapsed < 86_400_000) return 3_600_000 - (elapsed % 3_600_000)
|
|
2032
|
-
return 86_400_000 - (elapsed % 86_400_000)
|
|
2033
|
-
}
|
|
2034
|
-
|
|
2035
|
-
// Isolated subtree so re-renders don't cascade into the parent's
|
|
2036
|
-
// virtualized table.
|
|
2037
|
-
function LastUpdatedLabel({ lastUpdated }: { lastUpdated: Date }) {
|
|
2038
|
-
const [, force] = useState(0)
|
|
2039
|
-
useEffect(() => {
|
|
2040
|
-
let id: ReturnType<typeof setTimeout>
|
|
2041
|
-
function schedule() {
|
|
2042
|
-
const delay = Math.max(1000, msToNextBucket(Date.now() - lastUpdated.getTime()))
|
|
2043
|
-
id = setTimeout(() => {
|
|
2044
|
-
force(t => t + 1)
|
|
2045
|
-
schedule()
|
|
2046
|
-
}, delay)
|
|
2047
|
-
}
|
|
2048
|
-
schedule()
|
|
2049
|
-
return () => clearTimeout(id)
|
|
2050
|
-
}, [lastUpdated])
|
|
2051
|
-
return (
|
|
2052
|
-
<div className="flex items-center gap-1.5 text-xs text-theme-text-tertiary">
|
|
2053
|
-
<Clock className="w-3.5 h-3.5" />
|
|
2054
|
-
<span>Updated {formatLastUpdatedBucket(Date.now() - lastUpdated.getTime())}</span>
|
|
2055
|
-
</div>
|
|
2056
|
-
)
|
|
2057
|
-
}
|
|
2058
|
-
|
|
2059
2022
|
export function ResourcesView({
|
|
2060
2023
|
namespaces, selectedResource, onResourceClick, onResourceClickYaml, onKindChange,
|
|
2061
2024
|
apiResources: apiResourcesProp,
|
|
@@ -2065,11 +2028,13 @@ export function ResourcesView({
|
|
|
2065
2028
|
resourceReasons,
|
|
2066
2029
|
resourceUnavailable: resourceUnavailableProp,
|
|
2067
2030
|
selectedKindQuery: selectedKindQueryProp,
|
|
2031
|
+
connectionState,
|
|
2068
2032
|
largeListGuard,
|
|
2069
2033
|
topPodMetrics,
|
|
2070
2034
|
topNodeMetrics,
|
|
2071
2035
|
certExpiry,
|
|
2072
2036
|
certExpiryError,
|
|
2037
|
+
auditBadges,
|
|
2073
2038
|
pinned = [],
|
|
2074
2039
|
togglePin = () => {},
|
|
2075
2040
|
isPinned = () => false,
|
|
@@ -2123,7 +2088,6 @@ export function ResourcesView({
|
|
|
2123
2088
|
const [regexMode, setRegexMode] = useState(false)
|
|
2124
2089
|
const [sortColumn, setSortColumn] = useState<string | null>(null)
|
|
2125
2090
|
const [sortDirection, setSortDirection] = useState<SortDirection>(null)
|
|
2126
|
-
const [lastUpdated, setLastUpdated] = useState<Date | null>(null)
|
|
2127
2091
|
// Filter state
|
|
2128
2092
|
const [columnFilters, setColumnFilters] = useState<Record<string, string[]>>(initialFilters.columnFilters)
|
|
2129
2093
|
const [problemFilters, setProblemFilters] = useState<string[]>(initialFilters.problemFilters)
|
|
@@ -2944,6 +2908,11 @@ export function ResourcesView({
|
|
|
2944
2908
|
params.set('resource', resourceNs ? `${resourceNs}/${resourceName}` : resourceName)
|
|
2945
2909
|
} else {
|
|
2946
2910
|
params.delete('resource')
|
|
2911
|
+
// `full` (over-list fullscreen) and `tab` are resource-scoped — when no
|
|
2912
|
+
// resource is selected they're stale; drop them so they can't leak onto a
|
|
2913
|
+
// later selection (e.g. after a kind switch or closing the drawer).
|
|
2914
|
+
params.delete('full')
|
|
2915
|
+
params.delete('tab')
|
|
2947
2916
|
}
|
|
2948
2917
|
|
|
2949
2918
|
const newPath = `${basePath}/${kindInfo.name}`
|
|
@@ -3237,23 +3206,6 @@ export function ResourcesView({
|
|
|
3237
3206
|
}, [resources])
|
|
3238
3207
|
const isLoading = selectedQuery?.isLoading ?? true
|
|
3239
3208
|
const selectedQueryError = selectedQuery?.error
|
|
3240
|
-
const refetchFn = selectedQuery?.refetch
|
|
3241
|
-
const dataUpdatedAt = selectedQuery?.dataUpdatedAt
|
|
3242
|
-
|
|
3243
|
-
const [refetch, isRefreshAnimating, refreshPhase] = useRefreshAnimation(() => refetchFn?.())
|
|
3244
|
-
|
|
3245
|
-
// React Query bumps dataUpdatedAt on no-op refetches (window focus,
|
|
3246
|
-
// mount, sibling subscribers); structural sharing returns the same
|
|
3247
|
-
// resources reference when data is byte-identical. Skip the timer
|
|
3248
|
-
// reset in that case — otherwise opening a filter drawer looks like
|
|
3249
|
-
// it triggered a real fetch.
|
|
3250
|
-
const lastDataRef = useRef<unknown>(undefined)
|
|
3251
|
-
useEffect(() => {
|
|
3252
|
-
if (!dataUpdatedAt) return
|
|
3253
|
-
if (resources === lastDataRef.current) return
|
|
3254
|
-
lastDataRef.current = resources
|
|
3255
|
-
setLastUpdated(new Date(dataUpdatedAt))
|
|
3256
|
-
}, [dataUpdatedAt, resources])
|
|
3257
3209
|
|
|
3258
3210
|
// Derive counts — prefer lightweight resourceCounts prop over full query data
|
|
3259
3211
|
const counts = useMemo(() => {
|
|
@@ -4014,9 +3966,10 @@ export function ResourcesView({
|
|
|
4014
3966
|
onNavigate,
|
|
4015
3967
|
certExpiry,
|
|
4016
3968
|
certExpiryError,
|
|
3969
|
+
auditBadges,
|
|
4017
3970
|
onOpenLogs,
|
|
4018
3971
|
onOpenWorkloadLogs,
|
|
4019
|
-
}), [onNavigate, certExpiry, certExpiryError, onOpenLogs, onOpenWorkloadLogs])
|
|
3972
|
+
}), [onNavigate, certExpiry, certExpiryError, auditBadges, onOpenLogs, onOpenWorkloadLogs])
|
|
4020
3973
|
|
|
4021
3974
|
return (
|
|
4022
3975
|
<ResourcesViewDataContext.Provider value={resourcesViewDataContextValue}>
|
|
@@ -4280,19 +4233,19 @@ export function ResourcesView({
|
|
|
4280
4233
|
</Tooltip>
|
|
4281
4234
|
)}
|
|
4282
4235
|
|
|
4283
|
-
{lastUpdated && <LastUpdatedLabel lastUpdated={lastUpdated} />}
|
|
4284
4236
|
{/* Column picker */}
|
|
4285
4237
|
<div className="relative" ref={columnPickerRef}>
|
|
4238
|
+
<Tooltip content="Configure columns">
|
|
4286
4239
|
<button
|
|
4287
4240
|
onClick={() => setShowColumnPicker(prev => !prev)}
|
|
4288
4241
|
className={clsx(
|
|
4289
4242
|
'p-2 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded-lg',
|
|
4290
4243
|
showColumnPicker && 'bg-theme-elevated text-theme-text-primary'
|
|
4291
4244
|
)}
|
|
4292
|
-
title="Configure columns"
|
|
4293
4245
|
>
|
|
4294
4246
|
<Columns3 className="w-4 h-4" />
|
|
4295
4247
|
</button>
|
|
4248
|
+
</Tooltip>
|
|
4296
4249
|
{showColumnPicker && (
|
|
4297
4250
|
<div className="absolute right-0 top-full mt-1 z-50 bg-theme-surface border border-theme-border rounded-lg shadow-lg py-1 min-w-[200px] max-h-[400px] flex flex-col">
|
|
4298
4251
|
<div className="shrink-0 px-3 py-2 border-b border-theme-border flex items-center justify-between">
|
|
@@ -4398,20 +4351,6 @@ export function ResourcesView({
|
|
|
4398
4351
|
</div>
|
|
4399
4352
|
)}
|
|
4400
4353
|
</div>
|
|
4401
|
-
<button
|
|
4402
|
-
onClick={refetch}
|
|
4403
|
-
disabled={isRefreshAnimating}
|
|
4404
|
-
className={clsx(
|
|
4405
|
-
'p-2 hover:bg-theme-elevated rounded-lg disabled:opacity-50 transition-colors duration-500',
|
|
4406
|
-
refreshPhase === 'success' ? 'text-emerald-400' : 'text-theme-text-secondary hover:text-theme-text-primary'
|
|
4407
|
-
)}
|
|
4408
|
-
title="Refresh"
|
|
4409
|
-
>
|
|
4410
|
-
{refreshPhase === 'success'
|
|
4411
|
-
? <Check className="w-4 h-4 stroke-[2.5]" />
|
|
4412
|
-
: <RefreshCw className={clsx('w-4 h-4', refreshPhase === 'spinning' && 'animate-spin')} />
|
|
4413
|
-
}
|
|
4414
|
-
</button>
|
|
4415
4354
|
{onCreateResource && (
|
|
4416
4355
|
<Tooltip content={`Create ${selectedKind.kind || 'resource'}`}>
|
|
4417
4356
|
<button
|
|
@@ -4462,6 +4401,14 @@ export function ResourcesView({
|
|
|
4462
4401
|
</button>
|
|
4463
4402
|
</Tooltip>
|
|
4464
4403
|
)}
|
|
4404
|
+
{/* Freshness/liveness status — trailing and divided off from the action
|
|
4405
|
+
buttons so it reads as a status, not another control. Resources has
|
|
4406
|
+
no PageHeader, so this toolbar is its home. */}
|
|
4407
|
+
<div className="mx-1 h-5 w-px bg-theme-border/60" />
|
|
4408
|
+
{/* The list is SSE-invalidated (near-real-time), so it reads
|
|
4409
|
+
"Auto-updating" with no refresh button — the stream keeps it
|
|
4410
|
+
current, so a manual refresh would only undercut the claim. */}
|
|
4411
|
+
<FreshnessControl mode="auto" connectionState={connectionState} />
|
|
4465
4412
|
</div>
|
|
4466
4413
|
|
|
4467
4414
|
{/* Bulk actions bar */}
|
|
@@ -5155,6 +5102,7 @@ interface CellContentProps {
|
|
|
5155
5102
|
}
|
|
5156
5103
|
|
|
5157
5104
|
function CellContent({ resource, kind, column, group, majorityNodeMinorVersion, extraColumn, nameHref }: CellContentProps) {
|
|
5105
|
+
const { auditBadges } = useContext(ResourcesViewDataContext)
|
|
5158
5106
|
// Parent-injected extra columns short-circuit the built-in switch.
|
|
5159
5107
|
// Used by hosts that inject leading columns (e.g. a multi-cluster Cluster column).
|
|
5160
5108
|
if (extraColumn) {
|
|
@@ -5167,6 +5115,8 @@ function CellContent({ resource, kind, column, group, majorityNodeMinorVersion,
|
|
|
5167
5115
|
if (column === 'name') {
|
|
5168
5116
|
const isTerminating = !!meta.deletionTimestamp
|
|
5169
5117
|
const nameClass = clsx('text-sm font-medium truncate block', isTerminating ? 'text-theme-text-tertiary line-through' : 'text-theme-text-primary')
|
|
5118
|
+
const audit = auditBadges?.[`${meta.namespace || ''}/${meta.name}`]
|
|
5119
|
+
const auditTotal = audit ? audit.danger + audit.warning : 0
|
|
5170
5120
|
return (
|
|
5171
5121
|
<div className="flex items-center gap-1.5 min-w-0">
|
|
5172
5122
|
<Tooltip content={meta.name}>
|
|
@@ -5184,6 +5134,16 @@ function CellContent({ resource, kind, column, group, majorityNodeMinorVersion,
|
|
|
5184
5134
|
)}
|
|
5185
5135
|
</Tooltip>
|
|
5186
5136
|
<CopyNameButton name={meta.name} />
|
|
5137
|
+
{auditTotal > 0 && audit && (
|
|
5138
|
+
<Tooltip content={audit.messages && audit.messages.length > 0
|
|
5139
|
+
? <AuditBadgeTooltip messages={audit.messages} />
|
|
5140
|
+
: `${auditTotal} audit ${auditTotal === 1 ? 'finding' : 'findings'}${audit.danger > 0 ? ` · ${audit.danger} danger` : ''}`}>
|
|
5141
|
+
<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)}>
|
|
5142
|
+
<AlertTriangle className="w-3 h-3" />
|
|
5143
|
+
{auditTotal}
|
|
5144
|
+
</span>
|
|
5145
|
+
</Tooltip>
|
|
5146
|
+
)}
|
|
5187
5147
|
{isTerminating && (
|
|
5188
5148
|
<Tooltip content="Resource is being deleted (has deletionTimestamp set). May be stuck due to finalizers.">
|
|
5189
5149
|
<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 +5982,7 @@ function ReplicaSetCell({ resource, column }: { resource: any; column: string })
|
|
|
6022
5982
|
}
|
|
6023
5983
|
|
|
6024
5984
|
function ServiceCell({ resource, column }: { resource: any; column: string }) {
|
|
5985
|
+
const { auditBadges } = useContext(ResourcesViewDataContext)
|
|
6025
5986
|
switch (column) {
|
|
6026
5987
|
case 'type': {
|
|
6027
5988
|
const status = getServiceStatus(resource)
|
|
@@ -6042,6 +6003,20 @@ function ServiceCell({ resource, column }: { resource: any; column: string }) {
|
|
|
6042
6003
|
)
|
|
6043
6004
|
}
|
|
6044
6005
|
case 'endpoints': {
|
|
6006
|
+
// getServiceEndpointsStatus can't see live pods, so it optimistically
|
|
6007
|
+
// reports "Active" for any service with a selector. The audit's
|
|
6008
|
+
// serviceNoMatchingPods check DOES resolve the selector against live pods —
|
|
6009
|
+
// the only badge-worthy finding a Service can carry — so when it fired,
|
|
6010
|
+
// trust it over the guess instead of showing a false-green "Active".
|
|
6011
|
+
const meta = resource.metadata || {}
|
|
6012
|
+
const flagged = auditBadges?.[`${meta.namespace || ''}/${meta.name}`]
|
|
6013
|
+
if (flagged && flagged.danger + flagged.warning > 0) {
|
|
6014
|
+
return (
|
|
6015
|
+
<span className={clsx('badge', flagged.danger > 0 ? 'status-unhealthy' : 'status-degraded')}>
|
|
6016
|
+
No endpoints
|
|
6017
|
+
</span>
|
|
6018
|
+
)
|
|
6019
|
+
}
|
|
6045
6020
|
const { status, color } = getServiceEndpointsStatus(resource)
|
|
6046
6021
|
return (
|
|
6047
6022
|
<span className={clsx('badge', color)}>
|