@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 +1 -1
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +43 -26
- package/src/components/resources/ResourcesView.tsx +18 -7
- 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/ui/ClusterName.tsx +41 -11
- package/src/components/ui/MiddleEllipsis.tsx +157 -0
- package/src/components/ui/drawer-components.tsx +53 -5
- package/src/components/ui/index.ts +2 -0
- package/src/components/workload/WorkloadView.tsx +35 -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
package/package.json
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
278
|
-
|
|
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
|
|
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
|
-
|
|
1699
|
-
|
|
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
|
-
}, [
|
|
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 =
|
|
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,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
|
+
}
|
|
@@ -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,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}
|
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
|
|
@@ -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
|
+
}
|