@skyhook-io/k8s-ui 1.5.7 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/k8s-ui",
3
- "version": "1.5.7",
3
+ "version": "1.5.8",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/skyhook-io/radar",
@@ -5,11 +5,14 @@ import {
5
5
  useRef,
6
6
  useState,
7
7
  } from 'react'
8
- import { ChevronDown, Check, Loader2, Search, X } from 'lucide-react'
8
+ import { ChevronDown, Check, Loader2, Search, Server, X } from 'lucide-react'
9
+ import { ClusterName } from '../ui/ClusterName'
9
10
  import { StatusDot, type StatusTone } from '../ui/status-tone'
10
11
 
11
12
  export interface ClusterSwitcherItem {
12
13
  id: string
14
+ /** Raw context / display string. ClusterName collapses GKE/EKS/AKS
15
+ * shapes; user-named clusters pass through unchanged. */
13
16
  name: string
14
17
  secondary?: string
15
18
  badge?: string
@@ -18,17 +21,17 @@ export interface ClusterSwitcherItem {
18
21
  status?: StatusTone
19
22
  /** Hard navigation target. Takes precedence over the parent's `onSelect`. */
20
23
  href?: string
21
- /** Tooltip — useful on disabled rows to explain why the row is inert. */
24
+ /** Native tooltip — useful on disabled rows to explain why the row is inert.
25
+ * ClusterName supplies its own tooltip for the cluster name itself; this
26
+ * is for row-level affordances (e.g. "Cluster offline — reconnect…"). */
22
27
  title?: string
23
28
  }
24
29
 
25
30
  export interface ClusterSwitcherProps {
26
31
  currentId?: string
32
+ /** Raw context / display string. Pass it as-is — the trigger renders
33
+ * through ClusterName, which handles parse + provider badge + tooltip. */
27
34
  currentName: string
28
- currentTooltip?: string
29
- triggerIcon?: ReactNode
30
- triggerPrefix?: ReactNode
31
- triggerMaxWidthClass?: string
32
35
  items: ClusterSwitcherItem[]
33
36
  onSelect?: (item: ClusterSwitcherItem) => void
34
37
  searchable?: boolean
@@ -43,15 +46,17 @@ export interface ClusterSwitcherProps {
43
46
  align?: 'left' | 'right'
44
47
  }
45
48
 
46
- const DEFAULT_TRIGGER_MAX_WIDTH = 'max-w-[120px] sm:max-w-[220px]'
49
+ // Trigger width cap. With middle-truncation kicking in, this is a
50
+ // horizontal-real-estate guard rather than a readability one. The cap
51
+ // grows with viewport so wide screens (where there's no real estate
52
+ // pressure) show full cluster names rather than middle-truncating
53
+ // pointlessly. The xl tier (~400px) fits names up to ~30 chars in
54
+ // full — comfortably covering parsed cluster names from any provider.
55
+ const TRIGGER_NAME_MAX_WIDTH = 'max-w-[160px] sm:max-w-[260px] xl:max-w-[400px]'
47
56
 
48
57
  export function ClusterSwitcher({
49
58
  currentId,
50
59
  currentName,
51
- currentTooltip,
52
- triggerIcon,
53
- triggerPrefix,
54
- triggerMaxWidthClass = DEFAULT_TRIGGER_MAX_WIDTH,
55
60
  items,
56
61
  onSelect,
57
62
  searchable = true,
@@ -169,7 +174,6 @@ export function ClusterSwitcher({
169
174
  type="button"
170
175
  onClick={() => setIsOpen(v => !v)}
171
176
  disabled={disabled || loading}
172
- title={currentTooltip ?? currentName}
173
177
  className={`
174
178
  flex items-center gap-1.5 px-2.5 py-1.5
175
179
  bg-theme-elevated border border-theme-border rounded text-sm font-medium
@@ -179,16 +183,25 @@ export function ClusterSwitcher({
179
183
  `}
180
184
  >
181
185
  {loading ? (
182
- <Loader2 className="w-3.5 h-3.5 animate-spin" />
186
+ <>
187
+ <Loader2 className="w-3.5 h-3.5 animate-spin" />
188
+ <span className={`${TRIGGER_NAME_MAX_WIDTH} block truncate`}>Switching…</span>
189
+ </>
183
190
  ) : (
184
- triggerIcon
191
+ // ClusterName parses the context (provider badge for GKE/EKS/AKS,
192
+ // raw name for custom kubeconfig) and middle-truncates to the cap.
193
+ // Server icon is the fallback badge for the no-provider case so
194
+ // the trigger always has a leading visual. Tooltip is suppressed
195
+ // while the dropdown is open — the popover already shows the raw
196
+ // context inline (per-row secondary line), and an extra hover
197
+ // tooltip would just overlap the search input.
198
+ <ClusterName
199
+ name={currentName}
200
+ fallbackBadge={<Server className="w-3.5 h-3.5 text-theme-text-secondary" />}
201
+ className={TRIGGER_NAME_MAX_WIDTH}
202
+ noTooltip={isOpen}
203
+ />
185
204
  )}
186
- {triggerPrefix && (
187
- <span className="text-theme-text-tertiary">{triggerPrefix}</span>
188
- )}
189
- <span className={`${triggerMaxWidthClass} truncate`}>
190
- {loading ? 'Switching...' : currentName}
191
- </span>
192
205
  <ChevronDown className={`w-3 h-3 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
193
206
  </button>
194
207
 
@@ -274,19 +287,23 @@ export function ClusterSwitcher({
274
287
  )}
275
288
  <div className="flex-1 min-w-0">
276
289
  <div className="flex items-center gap-1.5">
277
- <span
278
- className={`text-sm font-medium truncate ${
290
+ {/* No tooltip on row names — each row already
291
+ renders the raw context inline below the
292
+ name (item.secondary), so the hover tooltip
293
+ would just repeat what's already visible. */}
294
+ <ClusterName
295
+ name={item.name}
296
+ noTooltip
297
+ className={`text-sm font-medium flex-1 ${
279
298
  isCurrent
280
299
  ? 'selection-text'
281
300
  : item.disabled
282
301
  ? 'text-theme-text-tertiary'
283
302
  : 'text-theme-text-primary'
284
303
  }`}
285
- >
286
- {item.name}
287
- </span>
304
+ />
288
305
  {item.badge && (
289
- <span className="shrink-0 ml-auto text-[10px] text-theme-text-tertiary bg-theme-elevated px-1 rounded">
306
+ <span className="shrink-0 text-[10px] text-theme-text-tertiary bg-theme-elevated px-1 rounded">
290
307
  {item.badge}
291
308
  </span>
292
309
  )}
@@ -1695,8 +1695,20 @@ function getInitialKindFromURL(
1695
1695
  locationPathname?: string,
1696
1696
  locationSearch?: string,
1697
1697
  ): SelectedKindInfo {
1698
- const pathname = locationPathname || window.location.pathname
1699
- const search = locationSearch || window.location.search
1698
+ // Prefer injected pathname/search from the host router. Using `||` would incorrectly fall back to
1699
+ // window when the host passes '' before hydration. SSR has no window.
1700
+ const pathname =
1701
+ locationPathname !== undefined
1702
+ ? locationPathname
1703
+ : typeof window !== 'undefined'
1704
+ ? window.location.pathname
1705
+ : ''
1706
+ const search =
1707
+ locationSearch !== undefined
1708
+ ? locationSearch
1709
+ : typeof window !== 'undefined'
1710
+ ? window.location.search
1711
+ : ''
1700
1712
  const base = basePath.replace(/\/$/, '') // strip trailing slash
1701
1713
  let kind: string | null = null
1702
1714
  if (pathname.startsWith(base + '/')) {
@@ -1751,8 +1763,8 @@ export function ResourcesView({
1751
1763
  pinned = [],
1752
1764
  togglePin = () => {},
1753
1765
  isPinned = () => false,
1754
- locationSearch = '',
1755
- locationPathname = '',
1766
+ locationSearch,
1767
+ locationPathname,
1756
1768
  onNavigate,
1757
1769
  basePath = '/resources',
1758
1770
  onOpenLogs,
@@ -1763,7 +1775,6 @@ export function ResourcesView({
1763
1775
  extraLeadingColumns,
1764
1776
  onRowSelect,
1765
1777
  }: ResourcesViewProps) {
1766
- const location = useMemo(() => ({ search: locationSearch, pathname: locationPathname }), [locationSearch, locationPathname])
1767
1778
  const initialFilters = getInitialFiltersFromURL()
1768
1779
  const [selectedKind, setSelectedKind] = useState<SelectedKindInfo>(() => getInitialKindFromURL(basePath, locationPathname, locationSearch))
1769
1780
  // Sync selectedKind from URL when locationPathname changes (e.g., browser back, external sidebar navigation)
@@ -1772,7 +1783,7 @@ export function ResourcesView({
1772
1783
  if (kindFromURL.name !== selectedKind.name || kindFromURL.group !== selectedKind.group) {
1773
1784
  setSelectedKind(kindFromURL)
1774
1785
  }
1775
- }, [locationPathname]) // eslint-disable-line react-hooks/exhaustive-deps
1786
+ }, [locationPathname, locationSearch]) // eslint-disable-line react-hooks/exhaustive-deps
1776
1787
  // Notify parent of selected kind changes (including initial mount)
1777
1788
  useEffect(() => {
1778
1789
  onSelectedKindChange?.(selectedKind)
@@ -2355,7 +2366,7 @@ export function ResourcesView({
2355
2366
  requestAnimationFrame(() => {
2356
2367
  isSyncingFromURL.current = false
2357
2368
  })
2358
- }, [location.search, location.pathname]) // Re-run when URL path or search params change
2369
+ }, [locationPathname, locationSearch]) // Re-run when injected URL path or search params change
2359
2370
 
2360
2371
  const navigate = useMemo(() => {
2361
2372
  if (!onNavigate) return (_pathOrObj: any, _opts?: any) => {}
@@ -0,0 +1,75 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
2
+ import { getCNPGClusterCertificateExpirations } from './resource-utils-cnpg'
3
+
4
+ describe('getCNPGClusterCertificateExpirations', () => {
5
+ beforeEach(() => {
6
+ vi.useFakeTimers()
7
+ vi.setSystemTime(new Date('2026-04-28T12:00:00Z'))
8
+ })
9
+ afterEach(() => {
10
+ vi.useRealTimers()
11
+ })
12
+
13
+ it('regression: future Go-format dates are not flagged as expired (issue #554)', () => {
14
+ const resource = {
15
+ status: {
16
+ certificates: {
17
+ expirations: {
18
+ 'mycluster-ca': '2026-07-27 08:27:41 +0000 UTC',
19
+ 'mycluster-server': '2026-07-27 08:27:41 +0000 UTC',
20
+ 'mycluster-replication': '2026-07-27 08:27:41 +0000 UTC',
21
+ },
22
+ },
23
+ },
24
+ }
25
+ const certs = getCNPGClusterCertificateExpirations(resource)
26
+ expect(certs).toHaveLength(3)
27
+ for (const cert of certs) {
28
+ expect(cert.daysUntilExpiry).toBeGreaterThan(0)
29
+ expect(cert.daysUntilExpiry).toBeLessThanOrEqual(91)
30
+ }
31
+ })
32
+
33
+ it('floors fractional days down so threshold banners do not misfire', () => {
34
+ // Pinned now: 2026-04-28T12:00:00Z; expiry 2026-07-27T00:00:00Z is 89.5
35
+ // days away. Math.floor pins this to 89, not 90 — locks day-boundary
36
+ // semantics so a future Math.ceil/round refactor doesn't shift the
37
+ // <30d / <7d alert thresholds.
38
+ const resource = {
39
+ status: {
40
+ certificates: {
41
+ expirations: { 'mycluster-ca': '2026-07-27 00:00:00 +0000 UTC' },
42
+ },
43
+ },
44
+ }
45
+ const [cert] = getCNPGClusterCertificateExpirations(resource)
46
+ expect(cert.daysUntilExpiry).toBe(89)
47
+ })
48
+
49
+ it('flags genuinely expired certificates as negative', () => {
50
+ const resource = {
51
+ status: {
52
+ certificates: {
53
+ expirations: {
54
+ 'mycluster-ca': '2026-04-27 08:27:41 +0000 UTC',
55
+ },
56
+ },
57
+ },
58
+ }
59
+ const [cert] = getCNPGClusterCertificateExpirations(resource)
60
+ expect(cert.daysUntilExpiry).toBeLessThan(0)
61
+ })
62
+
63
+ it('maps unparseable values to the -1 sentinel (renders as "expired")', () => {
64
+ const resource = {
65
+ status: { certificates: { expirations: { 'mycluster-ca': 'garbage' } } },
66
+ }
67
+ const [cert] = getCNPGClusterCertificateExpirations(resource)
68
+ expect(cert.daysUntilExpiry).toBe(-1)
69
+ })
70
+
71
+ it('returns empty list when no expirations are present', () => {
72
+ expect(getCNPGClusterCertificateExpirations({})).toEqual([])
73
+ expect(getCNPGClusterCertificateExpirations({ status: {} })).toEqual([])
74
+ })
75
+ })
@@ -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,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
+ }
@@ -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,6 +1,8 @@
1
1
  export { Tooltip } from './Tooltip'
2
2
  export { PaneLoader } from './PaneLoader'
3
3
  export { ClusterName } from './ClusterName'
4
+ export { MiddleEllipsis } from './MiddleEllipsis'
5
+ export type { MiddleEllipsisProps } from './MiddleEllipsis'
4
6
  export { EmptyState } from './EmptyState'
5
7
  export type { EmptyStateTone, EmptyStateVariant } from './EmptyState'
6
8
  export { FilterPill } from './FilterPill'
@@ -85,6 +85,11 @@ interface WorkloadViewProps {
85
85
  eventsLoading?: boolean
86
86
  /** Topology data for hierarchy building */
87
87
  topology?: any
88
+ resourceFocusedK8sEvents?: TimelineEvent[]
89
+ resourceFocusedUpdates?: TimelineEvent[]
90
+ resourceFocusedEventsLoading?: boolean
91
+ resourceFocusedK8sError?: Error | null
92
+ resourceFocusedUpdatesError?: Error | null
88
93
 
89
94
  // ── Capabilities ─────────────────────────────────────────────────────────
90
95
  /** Whether secrets can be updated */
@@ -160,6 +165,11 @@ export function WorkloadView({
160
165
  allEvents,
161
166
  eventsLoading = false,
162
167
  topology,
168
+ resourceFocusedK8sEvents,
169
+ resourceFocusedUpdates,
170
+ resourceFocusedEventsLoading = false,
171
+ resourceFocusedK8sError = null,
172
+ resourceFocusedUpdatesError = null,
163
173
  // Capabilities
164
174
  canUpdateSecrets,
165
175
  // Mutations
@@ -457,6 +467,11 @@ export function WorkloadView({
457
467
  rendererOverrides={rendererOverrides}
458
468
  resolvedEnvFrom={resolvedEnvFrom}
459
469
  renderMetrics={renderMetricsTab}
470
+ events={resourceFocusedK8sEvents}
471
+ eventsLoading={resourceFocusedEventsLoading}
472
+ updates={resourceFocusedUpdates}
473
+ eventsError={resourceFocusedK8sError}
474
+ updatesError={resourceFocusedUpdatesError}
460
475
  />
461
476
  {renderOverviewExtra && (
462
477
  <div className="px-4 pb-4">
@@ -603,6 +618,11 @@ export function WorkloadView({
603
618
  onSwitchToTimeline={() => handleSetTab('timeline')}
604
619
  rendererOverrides={rendererOverrides}
605
620
  resolvedEnvFrom={resolvedEnvFrom}
621
+ events={resourceFocusedK8sEvents}
622
+ eventsLoading={resourceFocusedEventsLoading}
623
+ updates={resourceFocusedUpdates}
624
+ eventsError={resourceFocusedK8sError}
625
+ updatesError={resourceFocusedUpdatesError}
606
626
  extraContent={renderOverviewExtra && renderOverviewExtra({ kind, namespace, name })}
607
627
  />
608
628
  )}
@@ -1088,6 +1108,11 @@ function InfoTab({
1088
1108
  onSwitchToTimeline,
1089
1109
  rendererOverrides,
1090
1110
  resolvedEnvFrom,
1111
+ events,
1112
+ eventsLoading,
1113
+ updates,
1114
+ eventsError,
1115
+ updatesError,
1091
1116
  extraContent,
1092
1117
  }: {
1093
1118
  resource: any
@@ -1103,6 +1128,11 @@ function InfoTab({
1103
1128
  onSwitchToTimeline?: () => void
1104
1129
  rendererOverrides?: RendererOverrides
1105
1130
  resolvedEnvFrom?: ResolvedEnvFrom
1131
+ events?: TimelineEvent[]
1132
+ eventsLoading?: boolean
1133
+ updates?: TimelineEvent[]
1134
+ eventsError?: Error | null
1135
+ updatesError?: Error | null
1106
1136
  extraContent?: ReactNode
1107
1137
  }) {
1108
1138
  if (isLoading) {
@@ -1133,6 +1163,11 @@ function InfoTab({
1133
1163
  onOpenLogs={onOpenLogs}
1134
1164
  rendererOverrides={rendererOverrides}
1135
1165
  resolvedEnvFrom={resolvedEnvFrom}
1166
+ events={events}
1167
+ eventsLoading={eventsLoading}
1168
+ updates={updates}
1169
+ eventsError={eventsError}
1170
+ updatesError={updatesError}
1136
1171
  eventsHint={onSwitchToTimeline && (
1137
1172
  <button
1138
1173
  onClick={onSwitchToTimeline}
@@ -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
@@ -0,0 +1,54 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { parseGoTimeString } from './parse-go-time'
3
+
4
+ describe('parseGoTimeString', () => {
5
+ it('parses Go default time format (no fractional seconds)', () => {
6
+ expect(parseGoTimeString('2026-07-27 08:27:41 +0000 UTC').toISOString()).toBe(
7
+ '2026-07-27T08:27:41.000Z',
8
+ )
9
+ })
10
+
11
+ it('truncates Go nanosecond precision to milliseconds', () => {
12
+ expect(
13
+ parseGoTimeString('2026-07-27 08:27:41.123456789 +0000 UTC').toISOString(),
14
+ ).toBe('2026-07-27T08:27:41.123Z')
15
+ })
16
+
17
+ it('pads fractional seconds to 3 digits (Go strips trailing zeros)', () => {
18
+ // Go emits ".1" / ".12" when nanoseconds end in zeros; without padding
19
+ // the constructed ISO string violates the .sss profile and Safari rejects
20
+ // it, re-introducing the bug for certs that happen to expire on these
21
+ // round nanoseconds.
22
+ expect(parseGoTimeString('2026-07-27 08:27:41.1 +0000 UTC').toISOString()).toBe(
23
+ '2026-07-27T08:27:41.100Z',
24
+ )
25
+ expect(parseGoTimeString('2026-07-27 08:27:41.12 +0000 UTC').toISOString()).toBe(
26
+ '2026-07-27T08:27:41.120Z',
27
+ )
28
+ expect(parseGoTimeString('2026-07-27 08:27:41.5 +0000 UTC').toISOString()).toBe(
29
+ '2026-07-27T08:27:41.500Z',
30
+ )
31
+ })
32
+
33
+ it('handles non-UTC offsets and ignores tz abbreviation', () => {
34
+ expect(parseGoTimeString('2026-07-27 08:27:41 -0700 PDT').toISOString()).toBe(
35
+ '2026-07-27T15:27:41.000Z',
36
+ )
37
+ })
38
+
39
+ it('falls back to native parser for ISO 8601', () => {
40
+ expect(parseGoTimeString('2026-07-27T08:27:41Z').toISOString()).toBe(
41
+ '2026-07-27T08:27:41.000Z',
42
+ )
43
+ })
44
+
45
+ it('returns Invalid Date for unparseable input', () => {
46
+ expect(isNaN(parseGoTimeString('not a date').getTime())).toBe(true)
47
+ })
48
+
49
+ it('regex normalizes nanosecond inputs without overflow into seconds', () => {
50
+ const d = parseGoTimeString('2026-07-27 08:27:41.999999999 +0000 UTC')
51
+ expect(d.getUTCMilliseconds()).toBe(999)
52
+ expect(d.getUTCSeconds()).toBe(41)
53
+ })
54
+ })
@@ -0,0 +1,23 @@
1
+ // Some Kubernetes controllers serialize timestamps using Go's default
2
+ // `time.Time.String()` format (e.g. "2026-07-27 08:27:41.123456789 +0000 UTC")
3
+ // when the field is typed as `string` instead of `metav1.Time`. ECMAScript
4
+ // only requires `Date(...)` to parse the ISO 8601 subset, so Safari rejects
5
+ // this format while V8 (Chrome/Node) accepts it leniently — which makes the
6
+ // bug invisible in test runners. Known affected schema: CloudNativePG
7
+ // `Cluster.status.certificates.expirations`.
8
+
9
+ const GO_TIME_PATTERN =
10
+ /^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2})(\.\d+)?\s+([+-]\d{2})(\d{2})\b/
11
+
12
+ export function parseGoTimeString(s: string): Date {
13
+ const m = s.match(GO_TIME_PATTERN)
14
+ if (m) {
15
+ const [, date, time, frac, tzHour, tzMin] = m
16
+ // Go's ".999999999" format strips trailing zeros, so nanos=100_000_000
17
+ // emits ".1". The ISO 8601 simplified profile requires exactly ".sss";
18
+ // pad to 3 digits before truncating so Safari accepts the result.
19
+ const ms = frac ? '.' + (frac.slice(1) + '000').slice(0, 3) : ''
20
+ return new Date(`${date}T${time}${ms}${tzHour}:${tzMin}`)
21
+ }
22
+ return new Date(s)
23
+ }