@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.
@@ -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 = new Date(expiryDate)
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
- /** Resource events — injected by the platform wrapper */
316
- events?: any[]
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
- <div className="flex items-center justify-center h-full text-theme-text-tertiary">
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 cluster-identity
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 no provider badge, no tooltip needed.
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 showBadge = !noBadge && parsed.provider !== null
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 needsTooltip = parsed.raw !== parsed.clusterName
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
- {showBadge && <ProviderBadge provider={parsed.provider!} />}
101
+ {showProviderBadge && <ProviderBadge provider={parsed.provider!} />}
102
+ {showFallback && fallbackBadge}
73
103
  {variant === 'stacked' ? (
74
- <span className="flex flex-col min-w-0">
75
- <span className="truncate">{parsed.clusterName}</span>
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
- <span className="truncate">{parsed.clusterName}</span>
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
- if (!events || events.length === 0) {
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&apos;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={false}>
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 (${events.length})`} defaultExpanded>
987
+ <Section title={`Recent Events (${visible.length})`} defaultExpanded>
942
988
  <div className="space-y-2 max-h-64 overflow-y-auto">
943
- {events.map((event, i) => (
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
- <div className="flex items-center justify-center h-32 text-theme-text-tertiary">Loading...</div>
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
- <div className="flex items-center justify-center h-32 text-theme-text-tertiary">Loading...</div>
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'
@@ -11,3 +11,4 @@ export * from './download'
11
11
  export * from './api-resources'
12
12
  export * from './skeleton-yaml'
13
13
  export * from './k8s-errors'
14
+ export * from './parse-go-time'
@@ -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', () => {
@@ -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
- p2k[plural] = r.kind
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