@skyhook-io/k8s-ui 1.5.6 → 1.5.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 -2
- package/src/assets/radar/radar-icon-loading.svg +147 -0
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +347 -0
- package/src/components/cluster-switcher/index.ts +2 -0
- package/src/components/resources/ResourcesView.tsx +20 -10
- package/src/components/resources/resource-utils-cnpg.test.ts +75 -0
- package/src/components/resources/resource-utils-cnpg.ts +2 -1
- package/src/components/shared/ResourceRendererDispatch.tsx +16 -4
- package/src/components/timeline/TimelineList.tsx +2 -4
- package/src/components/topology/TopologyGraph.tsx +2 -8
- package/src/components/ui/ClusterName.tsx +41 -11
- package/src/components/ui/MiddleEllipsis.tsx +157 -0
- package/src/components/ui/PaneLoader.tsx +21 -0
- package/src/components/ui/drawer-components.tsx +53 -5
- package/src/components/ui/index.ts +3 -0
- package/src/components/workload/WorkloadView.tsx +39 -8
- package/src/index.ts +3 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/navigation.test.ts +11 -0
- package/src/utils/navigation.ts +4 -1
- package/src/utils/parse-go-time.test.ts +54 -0
- package/src/utils/parse-go-time.ts +23 -0
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import type { StatusBadge } from './resource-utils'
|
|
4
4
|
import { healthColors, formatAge, formatDuration } from './resource-utils'
|
|
5
|
+
import { parseGoTimeString } from '../../utils/parse-go-time'
|
|
5
6
|
|
|
6
7
|
// ============================================================================
|
|
7
8
|
// CNPG CLUSTER UTILITIES
|
|
@@ -195,7 +196,7 @@ export function getCNPGClusterCertificateExpirations(resource: any): CNPGCertifi
|
|
|
195
196
|
if (!expirations || typeof expirations !== 'object') return []
|
|
196
197
|
const now = new Date()
|
|
197
198
|
return Object.entries(expirations).map(([secretName, expiryDate]: [string, any]) => {
|
|
198
|
-
const expiry =
|
|
199
|
+
const expiry = parseGoTimeString(String(expiryDate))
|
|
199
200
|
const daysUntilExpiry = isNaN(expiry.getTime())
|
|
200
201
|
? -1 // treat unparseable dates as expired/critical
|
|
201
202
|
: Math.floor((expiry.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
|
|
@@ -200,7 +200,7 @@ import {
|
|
|
200
200
|
AzureManagedMachinePoolRenderer,
|
|
201
201
|
AzureMachineRenderer,
|
|
202
202
|
} from '../resources/renderers'
|
|
203
|
-
import type { SelectedResource, Relationships, ResourceRef, SecretCertificateInfo, ResolvedEnvFrom } from '../../types'
|
|
203
|
+
import type { SelectedResource, Relationships, ResourceRef, SecretCertificateInfo, ResolvedEnvFrom, TimelineEvent } from '../../types'
|
|
204
204
|
import type { CopyHandler } from '../ui/drawer-components'
|
|
205
205
|
import { AlertBanner } from '../ui/drawer-components'
|
|
206
206
|
|
|
@@ -312,10 +312,19 @@ interface ResourceRendererDispatchProps {
|
|
|
312
312
|
eventsHint?: React.ReactNode
|
|
313
313
|
/** When provided, sidebar sections (related resources, events, labels, annotations, metadata) are passed to this render prop instead of being rendered inline */
|
|
314
314
|
renderSidebar?: (sections: React.ReactNode) => React.ReactNode
|
|
315
|
-
/**
|
|
316
|
-
|
|
315
|
+
/** K8s events for the focused resource — always shown (no toggle hides them)
|
|
316
|
+
* so resource history can't go missing. */
|
|
317
|
+
events?: TimelineEvent[]
|
|
317
318
|
/** Whether events are still loading */
|
|
318
319
|
eventsLoading?: boolean
|
|
320
|
+
/** Resource update events (informer/historical diffs) — hidden behind a
|
|
321
|
+
* toggle in the Recent Events section because they can be very high-volume
|
|
322
|
+
* for a flapping resource. */
|
|
323
|
+
updates?: TimelineEvent[]
|
|
324
|
+
/** Errors from the events / updates queries — surfaced inline in the
|
|
325
|
+
* Recent Events section so a partial failure doesn't render as empty. */
|
|
326
|
+
eventsError?: Error | null
|
|
327
|
+
updatesError?: Error | null
|
|
319
328
|
/** Render prop for Prometheus metrics charts — injected by the platform wrapper */
|
|
320
329
|
renderMetrics?: (props: { kind: string; namespace: string; name: string }) => React.ReactNode
|
|
321
330
|
}
|
|
@@ -337,6 +346,9 @@ export function ResourceRendererDispatch({
|
|
|
337
346
|
renderSidebar,
|
|
338
347
|
events,
|
|
339
348
|
eventsLoading,
|
|
349
|
+
updates,
|
|
350
|
+
eventsError,
|
|
351
|
+
updatesError,
|
|
340
352
|
renderMetrics,
|
|
341
353
|
resolvedEnvFrom,
|
|
342
354
|
rendererOverrides,
|
|
@@ -353,7 +365,7 @@ export function ResourceRendererDispatch({
|
|
|
353
365
|
const sidebarContent = showCommonSections && (
|
|
354
366
|
<>
|
|
355
367
|
<RelatedResourcesSection relationships={relationships} onNavigate={onNavigate} />
|
|
356
|
-
{kind !== 'events' && <EventsSection events={events || []} isLoading={eventsLoading ?? false} hint={eventsHint} />}
|
|
368
|
+
{kind !== 'events' && <EventsSection events={events || []} updates={updates || []} isLoading={eventsLoading ?? false} eventsError={eventsError ?? null} updatesError={updatesError ?? null} hint={eventsHint} />}
|
|
357
369
|
<LabelsSection data={data} />
|
|
358
370
|
<AnnotationsSection data={data} />
|
|
359
371
|
<MetadataSection data={data} />
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useState, useMemo, useRef, useEffect } from 'react'
|
|
2
2
|
import { useRefreshAnimation } from '../../hooks/useRefreshAnimation'
|
|
3
|
+
import { PaneLoader } from '../ui/PaneLoader'
|
|
3
4
|
import {
|
|
4
5
|
AlertCircle,
|
|
5
6
|
CheckCircle,
|
|
@@ -403,10 +404,7 @@ export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasL
|
|
|
403
404
|
{/* Timeline content */}
|
|
404
405
|
<div className="flex-1 overflow-auto">
|
|
405
406
|
{isLoading ? (
|
|
406
|
-
<
|
|
407
|
-
<RefreshCw className="w-5 h-5 animate-spin mr-2" />
|
|
408
|
-
Loading timeline...
|
|
409
|
-
</div>
|
|
407
|
+
<PaneLoader label="Loading timeline…" className="h-full" />
|
|
410
408
|
) : filteredActivity.length === 0 ? (
|
|
411
409
|
<div className="flex flex-col items-center justify-center h-full text-theme-text-tertiary">
|
|
412
410
|
<Clock className="w-12 h-12 mb-4 opacity-50" />
|
|
@@ -21,6 +21,7 @@ import '@xyflow/react/dist/style.css'
|
|
|
21
21
|
import { toCanvas } from 'html-to-image'
|
|
22
22
|
|
|
23
23
|
import { AlertTriangle, Download, LayoutGrid, Loader2, Maximize, Minus, Pause, Play, Plus, RotateCw, Shield, Workflow } from 'lucide-react'
|
|
24
|
+
import { PaneLoader } from '../ui/PaneLoader'
|
|
24
25
|
import { Tooltip } from '../ui/Tooltip'
|
|
25
26
|
import { useToast } from '../ui/Toast'
|
|
26
27
|
import { useRegisterShortcuts } from '../../hooks/useKeyboardShortcuts'
|
|
@@ -653,14 +654,7 @@ export function TopologyGraph({
|
|
|
653
654
|
}, [selectedNodeId, setNodes])
|
|
654
655
|
|
|
655
656
|
if (!topology) {
|
|
656
|
-
return
|
|
657
|
-
<div className="flex-1 flex items-center justify-center text-theme-text-secondary">
|
|
658
|
-
<div className="text-center">
|
|
659
|
-
<Loader2 className="w-6 h-6 animate-spin mx-auto mb-2 opacity-50" />
|
|
660
|
-
<p className="text-sm">Loading topology...</p>
|
|
661
|
-
</div>
|
|
662
|
-
</div>
|
|
663
|
-
)
|
|
657
|
+
return <PaneLoader label="Loading topology…" className="flex-1" />
|
|
664
658
|
}
|
|
665
659
|
|
|
666
660
|
if (topology.nodes.length === 0) {
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { type ReactNode, useCallback, useState } from 'react'
|
|
2
|
+
import { MiddleEllipsis } from './MiddleEllipsis'
|
|
1
3
|
import { Tooltip } from './Tooltip'
|
|
2
4
|
import { parseContextName } from '../../utils/context-name'
|
|
3
5
|
import type { ParsedContextName } from '../../utils/context-name'
|
|
@@ -10,8 +12,14 @@ import azureLogo from './provider-logos/azure.svg'
|
|
|
10
12
|
// cluster identity surfaced as primary text and provider/region pushed
|
|
11
13
|
// into supporting metadata. Wraps parseContextName from utils/context-name
|
|
12
14
|
// so all surfaces (cluster cards, table cells, column headers, switcher
|
|
13
|
-
// dropdowns, breadcrumb, error views) share identical
|
|
14
|
-
// rendering.
|
|
15
|
+
// trigger + dropdowns, breadcrumb, error views) share identical
|
|
16
|
+
// cluster-identity rendering.
|
|
17
|
+
//
|
|
18
|
+
// Width-aware: the name middle-truncates to fit its container so long
|
|
19
|
+
// strings (`gke_proj_us-east1-b_prod-cluster-us-east1`, custom user names)
|
|
20
|
+
// keep both ends readable. Tooltip surfaces the raw whenever EITHER the
|
|
21
|
+
// parse collapsed something (`gke_…` → `prod-cluster-us-east1`) OR the
|
|
22
|
+
// rendered name is being middle-truncated to fit.
|
|
15
23
|
//
|
|
16
24
|
// Variants:
|
|
17
25
|
// inline — name + small provider logo, fits in a table cell or
|
|
@@ -20,7 +28,9 @@ import azureLogo from './provider-logos/azure.svg'
|
|
|
20
28
|
// for card-sized surfaces
|
|
21
29
|
//
|
|
22
30
|
// User-named clusters that don't match a known shape pass through
|
|
23
|
-
// unchanged
|
|
31
|
+
// unchanged. A `fallbackBadge` prop lets surfaces that always want a
|
|
32
|
+
// leading visual (e.g. the cluster-switcher trigger) supply one for the
|
|
33
|
+
// no-provider case.
|
|
24
34
|
|
|
25
35
|
type Provider = NonNullable<ParsedContextName['provider']>
|
|
26
36
|
|
|
@@ -38,10 +48,21 @@ interface Props {
|
|
|
38
48
|
name: string
|
|
39
49
|
/** Visual shape. Default: inline. */
|
|
40
50
|
variant?: 'inline' | 'stacked'
|
|
41
|
-
/** Suppress the provider badge — use when context already conveys provider.
|
|
51
|
+
/** Suppress the provider badge — use when context already conveys provider.
|
|
52
|
+
* Also suppresses `fallbackBadge`; `noBadge` wins when both are set. */
|
|
42
53
|
noBadge?: boolean
|
|
54
|
+
/** Rendered in the badge slot when no provider is detected and `noBadge`
|
|
55
|
+
* is not set. Lets the cluster switcher trigger keep a Server-icon
|
|
56
|
+
* fallback for custom kubeconfig names without forcing every consumer
|
|
57
|
+
* to ship one. Ignored when `noBadge` is set. */
|
|
58
|
+
fallbackBadge?: ReactNode
|
|
43
59
|
/** Optional className on the outer span. */
|
|
44
60
|
className?: string
|
|
61
|
+
/** Suppress the hover tooltip even when the parsed name was collapsed
|
|
62
|
+
* or middle-truncated. Use when the surrounding chrome already
|
|
63
|
+
* discloses the raw context (e.g. inside an open switcher dropdown
|
|
64
|
+
* where the tooltip would overlap the popover content). */
|
|
65
|
+
noTooltip?: boolean
|
|
45
66
|
}
|
|
46
67
|
|
|
47
68
|
function ProviderBadge({ provider }: { provider: Provider }) {
|
|
@@ -60,19 +81,28 @@ function ProviderBadge({ provider }: { provider: Provider }) {
|
|
|
60
81
|
)
|
|
61
82
|
}
|
|
62
83
|
|
|
63
|
-
export function ClusterName({ name, variant = 'inline', noBadge, className }: Props) {
|
|
84
|
+
export function ClusterName({ name, variant = 'inline', noBadge, fallbackBadge, className, noTooltip }: Props) {
|
|
64
85
|
const parsed = parseContextName(name)
|
|
86
|
+
const [truncated, setTruncated] = useState(false)
|
|
87
|
+
const onTruncatedChange = useCallback((t: boolean) => setTruncated(t), [])
|
|
65
88
|
|
|
66
|
-
const
|
|
89
|
+
const hasProvider = parsed.provider !== null
|
|
90
|
+
const showProviderBadge = !noBadge && hasProvider
|
|
91
|
+
const showFallback = !noBadge && !hasProvider && fallbackBadge != null
|
|
67
92
|
const showRegion = parsed.region !== null && variant === 'stacked'
|
|
68
|
-
const
|
|
93
|
+
const collapsed = parsed.raw !== parsed.clusterName
|
|
94
|
+
// Tooltip when there's something to disclose — either we collapsed the
|
|
95
|
+
// raw, or the displayed name is being middle-truncated to fit. Callers
|
|
96
|
+
// can opt out via `noTooltip` when the raw is already visible elsewhere.
|
|
97
|
+
const needsTooltip = !noTooltip && (collapsed || truncated)
|
|
69
98
|
|
|
70
99
|
const body = (
|
|
71
100
|
<span className={['inline-flex items-center gap-1.5 min-w-0', className ?? ''].join(' ')}>
|
|
72
|
-
{
|
|
101
|
+
{showProviderBadge && <ProviderBadge provider={parsed.provider!} />}
|
|
102
|
+
{showFallback && fallbackBadge}
|
|
73
103
|
{variant === 'stacked' ? (
|
|
74
|
-
<span className="flex flex-col min-w-0">
|
|
75
|
-
<
|
|
104
|
+
<span className="flex flex-col min-w-0 flex-1">
|
|
105
|
+
<MiddleEllipsis text={parsed.clusterName} onTruncatedChange={onTruncatedChange} />
|
|
76
106
|
{showRegion && (
|
|
77
107
|
<span className="text-[10px] text-theme-text-tertiary truncate">
|
|
78
108
|
{parsed.provider} · {parsed.region}
|
|
@@ -80,7 +110,7 @@ export function ClusterName({ name, variant = 'inline', noBadge, className }: Pr
|
|
|
80
110
|
)}
|
|
81
111
|
</span>
|
|
82
112
|
) : (
|
|
83
|
-
<
|
|
113
|
+
<MiddleEllipsis text={parsed.clusterName} onTruncatedChange={onTruncatedChange} />
|
|
84
114
|
)}
|
|
85
115
|
</span>
|
|
86
116
|
)
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
// Renders text on a single line, fitted to its parent's width. When the full
|
|
4
|
+
// string overflows, it's truncated from the middle (`gke_koala…us-east1`)
|
|
5
|
+
// rather than the end, so cluster context strings like
|
|
6
|
+
// `gke_koalabackend_us-east1-b_prod-cluster-us-east1` keep both the
|
|
7
|
+
// identifying prefix and the region/role suffix.
|
|
8
|
+
//
|
|
9
|
+
// The trick: a "ghost" copy of the full text sits in normal flow but is
|
|
10
|
+
// visually hidden, while the visible truncated text overlays it absolutely.
|
|
11
|
+
// The ghost lets flex layout know our PREFERRED width is the full text — so
|
|
12
|
+
// when an ancestor's max-width grows (e.g. on a viewport breakpoint change),
|
|
13
|
+
// the wrapper re-grows back instead of staying trapped at the truncated
|
|
14
|
+
// render's width. Without it the layout settles into a fixed point: the
|
|
15
|
+
// flex parent shrink-wraps to the truncated content, MiddleEllipsis sees
|
|
16
|
+
// the shrunken width, keeps truncating; widening doesn't recover.
|
|
17
|
+
//
|
|
18
|
+
// Concretely, removing the ghost makes the trigger render full-width on
|
|
19
|
+
// first paint, then collapse to the truncated width on the first
|
|
20
|
+
// ResizeObserver tick, and stay collapsed forever even when the viewport
|
|
21
|
+
// widens. The ghost is load-bearing — don't simplify it away.
|
|
22
|
+
//
|
|
23
|
+
// Place inside a width-constrained container (e.g. a button child with
|
|
24
|
+
// `max-w-[…]`). The wrapper itself takes `width: 100%` of that container.
|
|
25
|
+
|
|
26
|
+
const ELLIPSIS = '…'
|
|
27
|
+
|
|
28
|
+
export interface MiddleEllipsisProps {
|
|
29
|
+
text: string
|
|
30
|
+
className?: string
|
|
31
|
+
/** Native browser tooltip. Opt-in: defaulting to the full text would
|
|
32
|
+
* duplicate any tooltip wrapper (e.g. `<Tooltip>`) higher up the tree. */
|
|
33
|
+
title?: string
|
|
34
|
+
/** Fires whenever the rendered text changes between full and truncated
|
|
35
|
+
* (edge-triggered, not level — won't fire on every render). Lets a parent
|
|
36
|
+
* gate behavior on actual truncation, e.g. show a custom tooltip only
|
|
37
|
+
* when the visible text isn't already the full string.
|
|
38
|
+
*
|
|
39
|
+
* Do NOT use the value to alter the width of the measured container — a
|
|
40
|
+
* truncated→untruncated swap that resizes the parent would oscillate
|
|
41
|
+
* through the ResizeObserver. Tooltips, badges, and other overlays/sibling
|
|
42
|
+
* affordances are fine; layout-affecting changes are not. */
|
|
43
|
+
onTruncatedChange?: (truncated: boolean) => void
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function MiddleEllipsis({ text, className, title, onTruncatedChange }: MiddleEllipsisProps) {
|
|
47
|
+
const wrapperRef = useRef<HTMLSpanElement>(null)
|
|
48
|
+
const [display, setDisplay] = useState(text)
|
|
49
|
+
const lastReportedTruncated = useRef<boolean | null>(null)
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
const node = wrapperRef.current
|
|
53
|
+
if (!node || typeof window === 'undefined') return
|
|
54
|
+
const ctx = document.createElement('canvas').getContext('2d')
|
|
55
|
+
if (!ctx) {
|
|
56
|
+
setDisplay(text)
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const recompute = () => {
|
|
61
|
+
// Use subpixel width (`getBoundingClientRect`) rather than the
|
|
62
|
+
// pixel-rounded `clientWidth`. measureText returns subpixel widths,
|
|
63
|
+
// and when the full text is JUST under the available space — say
|
|
64
|
+
// ctx.measureText says 173.4px and clientWidth rounds to 173 — the
|
|
65
|
+
// integer comparison decides we don't fit and middle-truncates a
|
|
66
|
+
// name that visually would have rendered fine.
|
|
67
|
+
const width = node.getBoundingClientRect().width
|
|
68
|
+
if (width <= 0) return
|
|
69
|
+
const cs = window.getComputedStyle(node)
|
|
70
|
+
// Include fontStyle in the shorthand so italic faces measure correctly.
|
|
71
|
+
// If the assignment ever silently fails (malformed family quoting,
|
|
72
|
+
// exotic computed values), the browser leaves ctx.font at its previous
|
|
73
|
+
// value — on first call that's the default `10px sans-serif`, which
|
|
74
|
+
// under-measures and would cause over-truncation. Detect by reading
|
|
75
|
+
// back: if ctx.font normalised to the default but we didn't ask for
|
|
76
|
+
// it, bail to full-text render (clipped by overflow:hidden) rather
|
|
77
|
+
// than render a wrongly-truncated string.
|
|
78
|
+
ctx.font = `${cs.fontStyle} ${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`
|
|
79
|
+
if (ctx.font === '10px sans-serif' && cs.fontSize !== '10px') {
|
|
80
|
+
setDisplay(text)
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
const next = fitMiddleTruncate(text, width, ctx)
|
|
84
|
+
setDisplay(next)
|
|
85
|
+
const truncated = next !== text
|
|
86
|
+
if (truncated !== lastReportedTruncated.current) {
|
|
87
|
+
lastReportedTruncated.current = truncated
|
|
88
|
+
onTruncatedChange?.(truncated)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
recompute()
|
|
93
|
+
const observer = new ResizeObserver(recompute)
|
|
94
|
+
observer.observe(node)
|
|
95
|
+
return () => observer.disconnect()
|
|
96
|
+
}, [text, onTruncatedChange])
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<span
|
|
100
|
+
ref={wrapperRef}
|
|
101
|
+
className={className}
|
|
102
|
+
title={title}
|
|
103
|
+
style={{
|
|
104
|
+
display: 'block',
|
|
105
|
+
position: 'relative',
|
|
106
|
+
overflow: 'hidden',
|
|
107
|
+
whiteSpace: 'nowrap',
|
|
108
|
+
minWidth: 0,
|
|
109
|
+
// width:100% so the wrapper claims the parent's full available
|
|
110
|
+
// width before measurement. Without it, a parent with extra room
|
|
111
|
+
// would let the wrapper shrink to the ghost's natural width — and
|
|
112
|
+
// the absolute-positioned visible overlay (inset:0) would clip to
|
|
113
|
+
// that shrunken box, making text middle-truncate even when the
|
|
114
|
+
// surrounding container had room to render it in full.
|
|
115
|
+
width: '100%',
|
|
116
|
+
}}
|
|
117
|
+
>
|
|
118
|
+
{/* Ghost: claims the full text's natural width in flow so flex parents
|
|
119
|
+
shrink-wrap to the *full* preferred size, not the truncated render. */}
|
|
120
|
+
<span aria-hidden="true" style={{ visibility: 'hidden' }}>
|
|
121
|
+
{text}
|
|
122
|
+
</span>
|
|
123
|
+
{/* Visible: overlays the ghost with the current truncated rendering. */}
|
|
124
|
+
<span style={{ position: 'absolute', inset: 0, overflow: 'hidden', whiteSpace: 'nowrap' }}>
|
|
125
|
+
{display}
|
|
126
|
+
</span>
|
|
127
|
+
</span>
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Binary-search the largest `n` such that `prefix(n) + … + suffix(n)` fits
|
|
132
|
+
// in `width`. Symmetric on purpose — keeping prefix and suffix balanced is
|
|
133
|
+
// the cheapest way to preserve the most identifying chars on both ends of
|
|
134
|
+
// names like `arn:aws:eks:…:cluster/prod` or `gke_…_prod-cluster-us-east1`.
|
|
135
|
+
function fitMiddleTruncate(
|
|
136
|
+
text: string,
|
|
137
|
+
width: number,
|
|
138
|
+
ctx: CanvasRenderingContext2D,
|
|
139
|
+
): string {
|
|
140
|
+
if (ctx.measureText(text).width <= width) return text
|
|
141
|
+
if (text.length <= 2) return text
|
|
142
|
+
|
|
143
|
+
let lo = 1
|
|
144
|
+
let hi = Math.floor((text.length - 1) / 2)
|
|
145
|
+
let best = 0
|
|
146
|
+
while (lo <= hi) {
|
|
147
|
+
const mid = (lo + hi) >> 1
|
|
148
|
+
const candidate = text.slice(0, mid) + ELLIPSIS + text.slice(-mid)
|
|
149
|
+
if (ctx.measureText(candidate).width <= width) {
|
|
150
|
+
best = mid
|
|
151
|
+
lo = mid + 1
|
|
152
|
+
} else {
|
|
153
|
+
hi = mid - 1
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return best === 0 ? ELLIPSIS : text.slice(0, best) + ELLIPSIS + text.slice(-best)
|
|
157
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import radarLoadingIcon from '../../assets/radar/radar-icon-loading.svg'
|
|
2
|
+
|
|
3
|
+
// PaneLoader — center-of-pane loading state. Animated radar icon stacked
|
|
4
|
+
// above a label so swapping the label across the loading chain doesn't
|
|
5
|
+
// shift the icon horizontally. Pin to the parent's fill via `className`
|
|
6
|
+
// (`flex-1`, `h-full`, `h-32`, `absolute inset-0`, etc.). The SVG self-
|
|
7
|
+
// animates (sweep arm + blips, `prefers-reduced-motion` honored).
|
|
8
|
+
export function PaneLoader({
|
|
9
|
+
label = 'Loading…',
|
|
10
|
+
className = '',
|
|
11
|
+
}: {
|
|
12
|
+
label?: string
|
|
13
|
+
className?: string
|
|
14
|
+
}) {
|
|
15
|
+
return (
|
|
16
|
+
<div className={`flex flex-col items-center justify-center gap-3 ${className}`}>
|
|
17
|
+
<img src={radarLoadingIcon} alt="" aria-hidden className="w-11 h-11" />
|
|
18
|
+
<span className="text-sm text-theme-text-tertiary">{label}</span>
|
|
19
|
+
</div>
|
|
20
|
+
)
|
|
21
|
+
}
|
|
@@ -913,13 +913,23 @@ function formatKindForRef(kind: string): string {
|
|
|
913
913
|
// ============================================================================
|
|
914
914
|
|
|
915
915
|
interface EventsSectionProps {
|
|
916
|
+
/** K8s events for the focused resource — always shown. */
|
|
916
917
|
events: TimelineEvent[]
|
|
918
|
+
/** Resource update events (informer/historical diffs) — hidden behind a
|
|
919
|
+
* toggle to avoid drowning out K8s events when a resource flaps. */
|
|
920
|
+
updates?: TimelineEvent[]
|
|
917
921
|
isLoading?: boolean
|
|
922
|
+
/** Errors from the K8s events / updates queries. Rendered inline so a
|
|
923
|
+
* failed fetch doesn't silently look like "no events." */
|
|
924
|
+
eventsError?: Error | null
|
|
925
|
+
updatesError?: Error | null
|
|
918
926
|
/** Optional hint shown below the event list (e.g. "See Timeline tab for related resources") */
|
|
919
927
|
hint?: React.ReactNode
|
|
920
928
|
}
|
|
921
929
|
|
|
922
|
-
export function EventsSection({ events, isLoading, hint }: EventsSectionProps) {
|
|
930
|
+
export function EventsSection({ events, updates = [], isLoading, eventsError, updatesError, hint }: EventsSectionProps) {
|
|
931
|
+
const [showUpdates, setShowUpdates] = useState(false)
|
|
932
|
+
|
|
923
933
|
if (isLoading) {
|
|
924
934
|
return (
|
|
925
935
|
<Section title="Recent Events" defaultExpanded>
|
|
@@ -928,19 +938,55 @@ export function EventsSection({ events, isLoading, hint }: EventsSectionProps) {
|
|
|
928
938
|
)
|
|
929
939
|
}
|
|
930
940
|
|
|
931
|
-
|
|
941
|
+
const updateCount = updates.length
|
|
942
|
+
const visible = showUpdates
|
|
943
|
+
? [...events, ...updates].sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
|
|
944
|
+
: events
|
|
945
|
+
|
|
946
|
+
const toggle = updateCount > 0 ? (
|
|
947
|
+
<Tooltip
|
|
948
|
+
className="max-w-xs leading-snug"
|
|
949
|
+
content={
|
|
950
|
+
<span style={{ whiteSpace: 'normal', display: 'inline-block' }}>
|
|
951
|
+
Changes are field-level diffs to this resource's spec or status (e.g. status flips, replica counts). Distinct from K8s events, which are messages emitted by the kubelet and controllers.
|
|
952
|
+
</span>
|
|
953
|
+
}
|
|
954
|
+
>
|
|
955
|
+
<button
|
|
956
|
+
onClick={(e) => { e.stopPropagation(); setShowUpdates(v => !v) }}
|
|
957
|
+
className="text-xs text-theme-text-tertiary hover:text-theme-text-secondary transition-colors"
|
|
958
|
+
>
|
|
959
|
+
{showUpdates ? `Hide ${updateCount} changes` : `Show ${updateCount} changes`}
|
|
960
|
+
</button>
|
|
961
|
+
</Tooltip>
|
|
962
|
+
) : null
|
|
963
|
+
|
|
964
|
+
const errors = (
|
|
965
|
+
<>
|
|
966
|
+
{eventsError && (
|
|
967
|
+
<div className="text-xs text-red-500 mt-2">Failed to load K8s events: {eventsError.message}</div>
|
|
968
|
+
)}
|
|
969
|
+
{updatesError && (
|
|
970
|
+
<div className="text-xs text-red-500 mt-2">Failed to load resource changes: {updatesError.message}</div>
|
|
971
|
+
)}
|
|
972
|
+
</>
|
|
973
|
+
)
|
|
974
|
+
|
|
975
|
+
if (visible.length === 0) {
|
|
932
976
|
return (
|
|
933
|
-
<Section title="Recent Events" defaultExpanded={
|
|
977
|
+
<Section title="Recent Events" defaultExpanded={!!(eventsError || updatesError)}>
|
|
934
978
|
<div className="text-sm text-theme-text-tertiary">No recent events</div>
|
|
979
|
+
{errors}
|
|
980
|
+
{toggle && <div className="mt-2">{toggle}</div>}
|
|
935
981
|
{hint && <div className="mt-2">{hint}</div>}
|
|
936
982
|
</Section>
|
|
937
983
|
)
|
|
938
984
|
}
|
|
939
985
|
|
|
940
986
|
return (
|
|
941
|
-
<Section title={`Recent Events (${
|
|
987
|
+
<Section title={`Recent Events (${visible.length})`} defaultExpanded>
|
|
942
988
|
<div className="space-y-2 max-h-64 overflow-y-auto">
|
|
943
|
-
{
|
|
989
|
+
{visible.map((event, i) => (
|
|
944
990
|
<div
|
|
945
991
|
key={`${event.id}-${i}`}
|
|
946
992
|
className={clsx(
|
|
@@ -973,6 +1019,8 @@ export function EventsSection({ events, isLoading, hint }: EventsSectionProps) {
|
|
|
973
1019
|
</div>
|
|
974
1020
|
))}
|
|
975
1021
|
</div>
|
|
1022
|
+
{errors}
|
|
1023
|
+
{toggle && <div className="mt-2">{toggle}</div>}
|
|
976
1024
|
{hint && <div className="mt-2">{hint}</div>}
|
|
977
1025
|
</Section>
|
|
978
1026
|
)
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
export { Tooltip } from './Tooltip'
|
|
2
|
+
export { PaneLoader } from './PaneLoader'
|
|
2
3
|
export { ClusterName } from './ClusterName'
|
|
4
|
+
export { MiddleEllipsis } from './MiddleEllipsis'
|
|
5
|
+
export type { MiddleEllipsisProps } from './MiddleEllipsis'
|
|
3
6
|
export { EmptyState } from './EmptyState'
|
|
4
7
|
export type { EmptyStateTone, EmptyStateVariant } from './EmptyState'
|
|
5
8
|
export { FilterPill } from './FilterPill'
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useState, useMemo, useEffect, useRef, useCallback, type ReactNode } from 'react'
|
|
2
2
|
import { flushSync } from 'react-dom'
|
|
3
3
|
import { useRefreshAnimation } from '../../hooks/useRefreshAnimation'
|
|
4
|
+
import { PaneLoader } from '../ui/PaneLoader'
|
|
4
5
|
import { useRegisterShortcuts } from '../../hooks/useKeyboardShortcuts'
|
|
5
6
|
import { clsx } from 'clsx'
|
|
6
7
|
import {
|
|
@@ -84,6 +85,11 @@ interface WorkloadViewProps {
|
|
|
84
85
|
eventsLoading?: boolean
|
|
85
86
|
/** Topology data for hierarchy building */
|
|
86
87
|
topology?: any
|
|
88
|
+
resourceFocusedK8sEvents?: TimelineEvent[]
|
|
89
|
+
resourceFocusedUpdates?: TimelineEvent[]
|
|
90
|
+
resourceFocusedEventsLoading?: boolean
|
|
91
|
+
resourceFocusedK8sError?: Error | null
|
|
92
|
+
resourceFocusedUpdatesError?: Error | null
|
|
87
93
|
|
|
88
94
|
// ── Capabilities ─────────────────────────────────────────────────────────
|
|
89
95
|
/** Whether secrets can be updated */
|
|
@@ -159,6 +165,11 @@ export function WorkloadView({
|
|
|
159
165
|
allEvents,
|
|
160
166
|
eventsLoading = false,
|
|
161
167
|
topology,
|
|
168
|
+
resourceFocusedK8sEvents,
|
|
169
|
+
resourceFocusedUpdates,
|
|
170
|
+
resourceFocusedEventsLoading = false,
|
|
171
|
+
resourceFocusedK8sError = null,
|
|
172
|
+
resourceFocusedUpdatesError = null,
|
|
162
173
|
// Capabilities
|
|
163
174
|
canUpdateSecrets,
|
|
164
175
|
// Mutations
|
|
@@ -426,7 +437,7 @@ export function WorkloadView({
|
|
|
426
437
|
{/* Content — viewTransitionName scopes View Transitions API cross-fade to this element */}
|
|
427
438
|
<div className="flex-1 overflow-y-auto" style={{ viewTransitionName: 'drawer-content' }}>
|
|
428
439
|
{resourceLoading ? (
|
|
429
|
-
<
|
|
440
|
+
<PaneLoader className="h-32" />
|
|
430
441
|
) : !resource ? (
|
|
431
442
|
<div className="flex items-center justify-center h-32 text-theme-text-tertiary">Resource not found</div>
|
|
432
443
|
) : showYaml ? (
|
|
@@ -456,6 +467,11 @@ export function WorkloadView({
|
|
|
456
467
|
rendererOverrides={rendererOverrides}
|
|
457
468
|
resolvedEnvFrom={resolvedEnvFrom}
|
|
458
469
|
renderMetrics={renderMetricsTab}
|
|
470
|
+
events={resourceFocusedK8sEvents}
|
|
471
|
+
eventsLoading={resourceFocusedEventsLoading}
|
|
472
|
+
updates={resourceFocusedUpdates}
|
|
473
|
+
eventsError={resourceFocusedK8sError}
|
|
474
|
+
updatesError={resourceFocusedUpdatesError}
|
|
459
475
|
/>
|
|
460
476
|
{renderOverviewExtra && (
|
|
461
477
|
<div className="px-4 pb-4">
|
|
@@ -602,6 +618,11 @@ export function WorkloadView({
|
|
|
602
618
|
onSwitchToTimeline={() => handleSetTab('timeline')}
|
|
603
619
|
rendererOverrides={rendererOverrides}
|
|
604
620
|
resolvedEnvFrom={resolvedEnvFrom}
|
|
621
|
+
events={resourceFocusedK8sEvents}
|
|
622
|
+
eventsLoading={resourceFocusedEventsLoading}
|
|
623
|
+
updates={resourceFocusedUpdates}
|
|
624
|
+
eventsError={resourceFocusedK8sError}
|
|
625
|
+
updatesError={resourceFocusedUpdatesError}
|
|
605
626
|
extraContent={renderOverviewExtra && renderOverviewExtra({ kind, namespace, name })}
|
|
606
627
|
/>
|
|
607
628
|
)}
|
|
@@ -640,7 +661,7 @@ export function WorkloadView({
|
|
|
640
661
|
{activeTab === 'yaml' && (
|
|
641
662
|
<div className="h-full overflow-auto">
|
|
642
663
|
{resourceLoading ? (
|
|
643
|
-
<
|
|
664
|
+
<PaneLoader className="h-32" />
|
|
644
665
|
) : !resource ? (
|
|
645
666
|
<div className="flex items-center justify-center h-32 text-theme-text-tertiary">Resource not found</div>
|
|
646
667
|
) : (
|
|
@@ -1087,6 +1108,11 @@ function InfoTab({
|
|
|
1087
1108
|
onSwitchToTimeline,
|
|
1088
1109
|
rendererOverrides,
|
|
1089
1110
|
resolvedEnvFrom,
|
|
1111
|
+
events,
|
|
1112
|
+
eventsLoading,
|
|
1113
|
+
updates,
|
|
1114
|
+
eventsError,
|
|
1115
|
+
updatesError,
|
|
1090
1116
|
extraContent,
|
|
1091
1117
|
}: {
|
|
1092
1118
|
resource: any
|
|
@@ -1102,15 +1128,15 @@ function InfoTab({
|
|
|
1102
1128
|
onSwitchToTimeline?: () => void
|
|
1103
1129
|
rendererOverrides?: RendererOverrides
|
|
1104
1130
|
resolvedEnvFrom?: ResolvedEnvFrom
|
|
1131
|
+
events?: TimelineEvent[]
|
|
1132
|
+
eventsLoading?: boolean
|
|
1133
|
+
updates?: TimelineEvent[]
|
|
1134
|
+
eventsError?: Error | null
|
|
1135
|
+
updatesError?: Error | null
|
|
1105
1136
|
extraContent?: ReactNode
|
|
1106
1137
|
}) {
|
|
1107
1138
|
if (isLoading) {
|
|
1108
|
-
return
|
|
1109
|
-
<div className="flex items-center justify-center h-full text-theme-text-tertiary">
|
|
1110
|
-
<RefreshCw className="w-5 h-5 animate-spin mr-2" />
|
|
1111
|
-
Loading...
|
|
1112
|
-
</div>
|
|
1113
|
-
)
|
|
1139
|
+
return <PaneLoader className="h-full" />
|
|
1114
1140
|
}
|
|
1115
1141
|
|
|
1116
1142
|
if (!resource) {
|
|
@@ -1137,6 +1163,11 @@ function InfoTab({
|
|
|
1137
1163
|
onOpenLogs={onOpenLogs}
|
|
1138
1164
|
rendererOverrides={rendererOverrides}
|
|
1139
1165
|
resolvedEnvFrom={resolvedEnvFrom}
|
|
1166
|
+
events={events}
|
|
1167
|
+
eventsLoading={eventsLoading}
|
|
1168
|
+
updates={updates}
|
|
1169
|
+
eventsError={eventsError}
|
|
1170
|
+
updatesError={updatesError}
|
|
1140
1171
|
eventsHint={onSwitchToTimeline && (
|
|
1141
1172
|
<button
|
|
1142
1173
|
onClick={onSwitchToTimeline}
|
package/src/index.ts
CHANGED
|
@@ -36,3 +36,6 @@ export * from './components/topology'
|
|
|
36
36
|
|
|
37
37
|
// Cluster audit (AuditCard, AuditAlerts, AuditFindingsTable)
|
|
38
38
|
export * from './components/audit'
|
|
39
|
+
|
|
40
|
+
// Cluster switcher (shared trigger+dropdown for OSS Radar and Radar Hub)
|
|
41
|
+
export * from './components/cluster-switcher'
|
package/src/utils/index.ts
CHANGED
|
@@ -135,6 +135,17 @@ describe('initNavigationMap', () => {
|
|
|
135
135
|
// Passing an already-plural kind should be idempotent
|
|
136
136
|
expect(kindToPlural('secretstores')).toBe('secretstores')
|
|
137
137
|
})
|
|
138
|
+
|
|
139
|
+
test('builtin core mappings win over colliding discovered resources', () => {
|
|
140
|
+
// metrics.k8s.io exposes a resource named "pods" with kind "PodMetrics".
|
|
141
|
+
// Without first-wins on builtins, this clobbers core "pods" → "Pod" and
|
|
142
|
+
// every Pod-keyed lookup (timeline kind filter, badge color, etc.) breaks.
|
|
143
|
+
initNavigationMap([
|
|
144
|
+
{ group: '', version: 'v1', kind: 'Pod', name: 'pods', namespaced: true, isCrd: false, verbs: ['get'] },
|
|
145
|
+
{ group: 'metrics.k8s.io', version: 'v1beta1', kind: 'PodMetrics', name: 'pods', namespaced: true, isCrd: false, verbs: ['get'] },
|
|
146
|
+
])
|
|
147
|
+
expect(pluralToKind('pods')).toBe('Pod')
|
|
148
|
+
})
|
|
138
149
|
})
|
|
139
150
|
|
|
140
151
|
describe('refToSelectedResource', () => {
|
package/src/utils/navigation.ts
CHANGED
|
@@ -51,7 +51,10 @@ export function initNavigationMap(resources: APIResource[]) {
|
|
|
51
51
|
const k2p: Record<string, string> = {}
|
|
52
52
|
for (const r of resources) {
|
|
53
53
|
const plural = r.name.toLowerCase()
|
|
54
|
-
|
|
54
|
+
// First-wins on plurals: BUILTIN_PLURAL_TO_KIND seeds canonical core mappings
|
|
55
|
+
// (e.g. "pods" → "Pod") so a colliding API resource (metrics.k8s.io exposes
|
|
56
|
+
// "pods" with kind "PodMetrics") cannot hijack the core mapping.
|
|
57
|
+
if (!(plural in p2k)) p2k[plural] = r.kind
|
|
55
58
|
k2p[r.kind.toLowerCase()] = plural
|
|
56
59
|
}
|
|
57
60
|
discoveredPluralToKind = p2k
|