@skyhook-io/k8s-ui 1.7.7 → 1.7.9

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.
Files changed (48) hide show
  1. package/package.json +6 -1
  2. package/src/components/charts/PrometheusChartsView.tsx +233 -0
  3. package/src/components/charts/index.ts +13 -0
  4. package/src/components/checks/ChecksView.tsx +5 -1
  5. package/src/components/gitops/GitOpsTableView.tsx +1 -2
  6. package/src/components/gitops/insights/GitOpsInsightViews.tsx +3 -3
  7. package/src/components/issues/IssuesView.tsx +374 -0
  8. package/src/components/issues/index.ts +24 -0
  9. package/src/components/issues/issues.test.ts +100 -0
  10. package/src/components/issues/severity.ts +125 -0
  11. package/src/components/issues/types.ts +194 -0
  12. package/src/components/resources/ResourcesView.tsx +2 -19
  13. package/src/components/resources/renderers/CAPIKubeadmControlPlaneRenderer.tsx +1 -1
  14. package/src/components/resources/renderers/CAPIMachineDeploymentRenderer.tsx +1 -1
  15. package/src/components/resources/renderers/CAPIMachineSetRenderer.tsx +1 -1
  16. package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +1 -1
  17. package/src/components/resources/renderers/CompositionRenderer.tsx +3 -3
  18. package/src/components/resources/renderers/CrossplanePackageRenderer.tsx +5 -5
  19. package/src/components/resources/renderers/CrossplaneProviderConfigRenderer.tsx +1 -1
  20. package/src/components/resources/renderers/KnativeSourceRenderer.tsx +1 -1
  21. package/src/components/resources/renderers/PodRenderer.test.tsx +53 -0
  22. package/src/components/resources/renderers/PodRenderer.tsx +7 -1
  23. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +1 -1
  24. package/src/components/resources/renderers/WorkloadRenderer.test.tsx +41 -0
  25. package/src/components/resources/renderers/WorkloadRenderer.tsx +49 -7
  26. package/src/components/resources/renderers/XRDRenderer.tsx +2 -2
  27. package/src/components/shared/DetailShell.tsx +107 -0
  28. package/src/components/shared/ManagedByChip.tsx +149 -16
  29. package/src/components/shared/ResourceRendererDispatch.test.tsx +45 -0
  30. package/src/components/shared/ResourceRendererDispatch.tsx +13 -1
  31. package/src/components/shared/index.ts +2 -1
  32. package/src/components/timeline/TimelineSwimlanes.tsx +1320 -0
  33. package/src/components/timeline/index.ts +1 -0
  34. package/src/components/topology/K8sResourceNode.tsx +60 -60
  35. package/src/components/topology/TopologyFilterSidebar.tsx +25 -3
  36. package/src/components/topology/TopologyGraph.tsx +168 -52
  37. package/src/components/topology/TopologySearch.tsx +17 -8
  38. package/src/components/topology/topology-search-match.test.ts +1 -1
  39. package/src/components/topology/topology.css +18 -0
  40. package/src/components/ui/Tooltip.tsx +37 -2
  41. package/src/components/workload/WorkloadView.tsx +118 -112
  42. package/src/index.ts +5 -0
  43. package/src/theme/components.css +35 -0
  44. package/src/types/core.ts +3 -2
  45. package/src/utils/env-from.ts +3 -0
  46. package/src/utils/index.ts +1 -0
  47. package/src/utils/replica-scalers.ts +9 -0
  48. package/src/components/resources/resources-search-sidebar-hint.test.ts +0 -85
@@ -54,6 +54,7 @@ export function Tooltip({
54
54
  const triggerRef = useRef<HTMLSpanElement>(null)
55
55
  const tooltipRef = useRef<HTMLSpanElement>(null)
56
56
  const timeoutRef = useRef<number | null>(null)
57
+ const hideTimeoutRef = useRef<number | null>(null)
57
58
  const rafRef = useRef<number | null>(null)
58
59
 
59
60
  const updatePosition = useCallback(() => {
@@ -94,12 +95,24 @@ export function Tooltip({
94
95
  clearTimeout(timeoutRef.current)
95
96
  timeoutRef.current = null
96
97
  }
98
+ if (hideTimeoutRef.current) {
99
+ clearTimeout(hideTimeoutRef.current)
100
+ hideTimeoutRef.current = null
101
+ }
97
102
  setIsVisible(false)
98
103
  setCoords(null)
99
104
  }
100
105
 
106
+ const cancelHide = () => {
107
+ if (hideTimeoutRef.current) {
108
+ clearTimeout(hideTimeoutRef.current)
109
+ hideTimeoutRef.current = null
110
+ }
111
+ }
112
+
101
113
  const showTooltip = () => {
102
114
  if (disabled || !content) return
115
+ cancelHide()
103
116
  timeoutRef.current = window.setTimeout(() => {
104
117
  // Singleton: hide whoever was visible before us, register self
105
118
  // as the new active tooltip. Guards against stuck duplicates.
@@ -116,6 +129,10 @@ export function Tooltip({
116
129
  clearTimeout(timeoutRef.current)
117
130
  timeoutRef.current = null
118
131
  }
132
+ if (hideTimeoutRef.current) {
133
+ clearTimeout(hideTimeoutRef.current)
134
+ hideTimeoutRef.current = null
135
+ }
119
136
  if (activeHide === hideRef.current) {
120
137
  activeHide = null
121
138
  }
@@ -123,6 +140,15 @@ export function Tooltip({
123
140
  setCoords(null)
124
141
  }
125
142
 
143
+ const scheduleHideTooltip = () => {
144
+ if (timeoutRef.current) {
145
+ clearTimeout(timeoutRef.current)
146
+ timeoutRef.current = null
147
+ }
148
+ if (hideTimeoutRef.current) return
149
+ hideTimeoutRef.current = window.setTimeout(hideTooltip, 80)
150
+ }
151
+
126
152
  useEffect(() => {
127
153
  if (isVisible) {
128
154
  // Second rAF re-centers using the measured tooltip size; without
@@ -144,6 +170,9 @@ export function Tooltip({
144
170
  if (timeoutRef.current) {
145
171
  clearTimeout(timeoutRef.current)
146
172
  }
173
+ if (hideTimeoutRef.current) {
174
+ clearTimeout(hideTimeoutRef.current)
175
+ }
147
176
  if (rafRef.current !== null) {
148
177
  cancelAnimationFrame(rafRef.current)
149
178
  }
@@ -169,6 +198,10 @@ export function Tooltip({
169
198
  clearTimeout(timeoutRef.current)
170
199
  timeoutRef.current = null
171
200
  }
201
+ if (hideTimeoutRef.current) {
202
+ clearTimeout(hideTimeoutRef.current)
203
+ hideTimeoutRef.current = null
204
+ }
172
205
  setIsVisible(false)
173
206
  setCoords(null)
174
207
  }
@@ -202,7 +235,7 @@ export function Tooltip({
202
235
  className={clsx('inline-flex max-w-full', wrapperClassName)}
203
236
  style={wrapperStyle}
204
237
  onMouseEnter={showTooltip}
205
- onMouseLeave={hideTooltip}
238
+ onMouseLeave={scheduleHideTooltip}
206
239
  onFocus={showTooltip}
207
240
  onBlur={hideTooltip}
208
241
  // pointerdown fires before click, so the tooltip is gone before
@@ -227,7 +260,7 @@ export function Tooltip({
227
260
  // max-w-xs (320px) + whitespace-normal, short tooltips
228
261
  // still fit on one line (content shorter than max-width)
229
262
  // and long ones wrap naturally near the trigger.
230
- 'max-w-xs whitespace-normal break-words pointer-events-none',
263
+ 'max-w-xs whitespace-normal break-words',
231
264
  className
232
265
  )}
233
266
  style={{
@@ -238,6 +271,8 @@ export function Tooltip({
238
271
  }}
239
272
  role="tooltip"
240
273
  aria-hidden={coords ? undefined : true}
274
+ onMouseEnter={cancelHide}
275
+ onMouseLeave={scheduleHideTooltip}
241
276
  >
242
277
  {content}
243
278
  </span>,
@@ -21,6 +21,7 @@ import {
21
21
  BarChart3,
22
22
  } from 'lucide-react'
23
23
  import type { TimelineEvent, ResourceRef, Relationships, SelectedResource, ResolvedEnvFrom } from '../../types'
24
+ import type { GitOpsStatus } from '../../types/gitops'
24
25
  import type { NavigateToResource } from '../../utils/navigation'
25
26
  import { refToSelectedResource, pluralToKind } from '../../utils/navigation'
26
27
  import { gitOpsOwnerFromRelationships, type GitOpsOwnerRef } from '../../utils/gitops-owner'
@@ -44,7 +45,8 @@ import {
44
45
  import { ResourceActionsBar } from '../shared/ResourceActionsBar'
45
46
  import { EditableYamlView, SaveSuccessAnimation } from '../shared/EditableYamlView'
46
47
  import { ResourceRendererDispatch, getResourceStatus, type RendererOverrides } from '../shared/ResourceRendererDispatch'
47
- import { ManagedByChip } from '../shared/ManagedByChip'
48
+ import { DetailShell, type DetailShellTab } from '../shared/DetailShell'
49
+ import { HelmManagedByChip, ManagedByChip, type HelmOwnerRef } from '../shared/ManagedByChip'
48
50
  import { getKindColorOutline, formatKindName } from '../ui/drawer-components'
49
51
 
50
52
  type TabType = 'overview' | 'timeline' | 'logs' | 'metrics' | 'yaml'
@@ -71,6 +73,20 @@ interface WorkloadViewProps {
71
73
  /** API group for CRD resources */
72
74
  group?: string
73
75
 
76
+ // ── Hosted chrome (expanded mode) ────────────────────────────────────────
77
+ /**
78
+ * A breadcrumb rendered above the identity header — e.g. when a larger
79
+ * surface (Radar Cloud's app page) hosts this view inside its own navigation.
80
+ * When set, the standalone back button is not rendered; `onBack` still backs
81
+ * the Escape shortcut.
82
+ */
83
+ breadcrumb?: ReactNode
84
+ /**
85
+ * Controls injected into the shell's tab-row scope slot — e.g. a cluster /
86
+ * workload picker in Radar Cloud. Absent in standalone Radar.
87
+ */
88
+ scopeControls?: ReactNode
89
+
74
90
  // ── Data (injected by wrapper) ──────────────────────────────────────────
75
91
  /** The resource data object */
76
92
  resource?: any
@@ -126,6 +142,22 @@ interface WorkloadViewProps {
126
142
  * visible (useful for hosts that haven't routed the GitOps tab yet).
127
143
  */
128
144
  onOpenGitOpsResource?: (ref: GitOpsOwnerRef) => void
145
+ /** Owner ref resolved by the host when relationships lack enough detail, e.g. Argo labels without namespace. */
146
+ resolvedGitOpsOwner?: GitOpsOwnerRef | null
147
+ /** True when the owner exists locally and can be opened as a GitOps detail page. */
148
+ gitOpsOwnerVerified?: boolean
149
+ /** True while the host is still resolving whether the owner exists locally. */
150
+ gitOpsOwnerPending?: boolean
151
+ /** Metadata key/value that caused GitOps ownership inference, when known. */
152
+ gitOpsOwnerSource?: string | null
153
+ /** Sync/health status for the GitOps owner, when the host can resolve it. */
154
+ gitOpsOwnerStatus?: GitOpsStatus | null
155
+ /** Native Helm release that manages this resource, when detected. */
156
+ helmOwner?: HelmOwnerRef | null
157
+ /** Metadata key/value that caused native Helm ownership inference, when known. */
158
+ helmOwnerSource?: string | null
159
+ /** Open the native Helm release drawer. */
160
+ onOpenHelmRelease?: (ref: HelmOwnerRef) => void
129
161
  /**
130
162
  * Open the GitOps detail page for the resource itself, when the resource
131
163
  * is a portal-classified GitOps CR (Argo Application/ApplicationSet/
@@ -185,6 +217,8 @@ export function WorkloadView({
185
217
  onExpand,
186
218
  initialTab,
187
219
  group,
220
+ breadcrumb,
221
+ scopeControls,
188
222
  // Data
189
223
  resource,
190
224
  relationships,
@@ -226,6 +260,14 @@ export function WorkloadView({
226
260
  resolvedEnvFrom,
227
261
  // GitOps
228
262
  onOpenGitOpsResource,
263
+ resolvedGitOpsOwner,
264
+ gitOpsOwnerVerified = true,
265
+ gitOpsOwnerPending = false,
266
+ gitOpsOwnerSource,
267
+ gitOpsOwnerStatus,
268
+ helmOwner,
269
+ helmOwnerSource,
270
+ onOpenHelmRelease,
229
271
  onNavigateGitOpsPath,
230
272
  }: WorkloadViewProps) {
231
273
  // Normalize kind: URL has plural lowercase, internal logic uses singular PascalCase
@@ -315,7 +357,8 @@ export function WorkloadView({
315
357
 
316
358
  // Metadata
317
359
  const metadata = useMemo(() => extractMetadata(kind, resource), [kind, resource])
318
- const gitopsOwner = useMemo(() => gitOpsOwnerFromRelationships(relationships), [relationships])
360
+ const relationshipGitOpsOwner = useMemo(() => gitOpsOwnerFromRelationships(relationships), [relationships])
361
+ const gitopsOwner = resolvedGitOpsOwner ?? relationshipGitOpsOwner
319
362
  // When the resource itself is a portal GitOps CR (Application, Kustomization,
320
363
  // HelmRelease, etc.), surface a link to its dedicated GitOps detail page —
321
364
  // the drawer's renderer is thorough but the tab has the tree + insights +
@@ -401,6 +444,18 @@ export function WorkloadView({
401
444
  const status = getResourceStatus(apiKind, resource)
402
445
 
403
446
  const showMetricsTab = isMetricsAvailable ? isMetricsAvailable(kind, resource) : false
447
+ const tabs: DetailShellTab<TabType>[] = [
448
+ { id: 'overview', label: 'Overview', icon: <Layers className="w-4 h-4" /> },
449
+ {
450
+ id: 'timeline',
451
+ label: 'Timeline',
452
+ icon: <Activity className="w-4 h-4" />,
453
+ badge: resourceEvents.length > 0 ? <span className="ml-1 badge-sm bg-theme-elevated">{resourceEvents.length}</span> : undefined,
454
+ },
455
+ { id: 'logs', label: 'Logs', icon: <Terminal className="w-4 h-4" />, hidden: !(allPods.length > 0 && renderLogsTab) },
456
+ { id: 'metrics', label: 'Metrics', icon: <BarChart3 className="w-4 h-4" />, hidden: !(showMetricsTab && renderMetricsTab) },
457
+ { id: 'yaml', label: 'YAML', icon: <FileText className="w-4 h-4" /> },
458
+ ]
404
459
 
405
460
  // ── Collapsed (drawer) mode ──────────────────────────────────────────────
406
461
  if (!expanded) {
@@ -465,9 +520,10 @@ export function WorkloadView({
465
520
  </button>
466
521
  </div>
467
522
  <p className="text-sm text-theme-text-tertiary">{namespace}</p>
468
- {(gitopsOwner || (gitOpsResourcePath && onNavigateGitOpsPath)) && (
523
+ {(gitopsOwner || helmOwner || (gitOpsResourcePath && onNavigateGitOpsPath)) && (
469
524
  <div className="mt-1 flex flex-wrap items-center gap-1.5">
470
- {gitopsOwner && <ManagedByChip owner={gitopsOwner} onOpen={onOpenGitOpsResource} />}
525
+ {gitopsOwner && <ManagedByChip owner={gitopsOwner} status={gitOpsOwnerStatus} verified={gitOpsOwnerVerified} pending={gitOpsOwnerPending} source={gitOpsOwnerSource} onOpen={onOpenGitOpsResource} />}
526
+ {helmOwner && <HelmManagedByChip owner={helmOwner} source={helmOwnerSource} onOpen={onOpenHelmRelease} />}
471
527
  {gitOpsResourcePath && onNavigateGitOpsPath && (
472
528
  <OpenInGitOpsChip onClick={() => onNavigateGitOpsPath(gitOpsResourcePath)} />
473
529
  )}
@@ -534,11 +590,10 @@ export function WorkloadView({
534
590
 
535
591
  // ── Expanded (full) mode ─────────────────────────────────────────────────
536
592
  return (
537
- <div className="flex flex-col h-full w-full bg-theme-surface">
538
- {/* Header */}
539
- <div className="shrink-0 border-b border-theme-border bg-theme-surface">
540
- <div className="px-6 py-3 flex items-start gap-4">
541
- {/* Back button */}
593
+ <DetailShell
594
+ breadcrumb={breadcrumb}
595
+ nav={
596
+ breadcrumb ? undefined : (
542
597
  <button
543
598
  onClick={onBack}
544
599
  className="p-1.5 mt-0.5 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded-lg transition-colors"
@@ -546,47 +601,52 @@ export function WorkloadView({
546
601
  >
547
602
  <ArrowLeft className="w-5 h-5" />
548
603
  </button>
549
-
550
- {/* Resource identity */}
551
- <div className="flex-1 min-w-0">
552
- <div className="flex items-center gap-3 mb-1">
553
- <h1 className="text-lg font-semibold text-theme-text-primary truncate">{name}</h1>
554
- <button
555
- onClick={() => copyToClipboard(name, 'name')}
556
- className="p-1 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded shrink-0"
557
- title="Copy name"
558
- >
559
- {copied === 'name' ? <Check className="w-3.5 h-3.5 text-green-400" /> : <Copy className="w-3.5 h-3.5" />}
560
- </button>
561
- </div>
562
- <div className="flex items-center gap-3 text-sm text-theme-text-secondary">
563
- <span className={clsx('badge', getKindColorOutline(apiKind))}>
564
- {formatKindName(apiKind)}
604
+ )
605
+ }
606
+ identity={
607
+ <>
608
+ <div className="flex items-center gap-3 mb-1">
609
+ <h1 className="text-lg font-semibold text-theme-text-primary truncate">{name}</h1>
610
+ <button
611
+ onClick={() => copyToClipboard(name, 'name')}
612
+ className="p-1 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded shrink-0"
613
+ title="Copy name"
614
+ >
615
+ {copied === 'name' ? <Check className="w-3.5 h-3.5 text-green-400" /> : <Copy className="w-3.5 h-3.5" />}
616
+ </button>
617
+ </div>
618
+ <div className="flex items-center gap-3 text-sm text-theme-text-secondary">
619
+ <span className={clsx('badge', getKindColorOutline(apiKind))}>
620
+ {formatKindName(apiKind)}
621
+ </span>
622
+ {status && (
623
+ <span className={clsx('badge', status.color)}>
624
+ {status.text}
565
625
  </span>
566
- {status && (
567
- <span className={clsx('badge', status.color)}>
568
- {status.text}
569
- </span>
570
- )}
571
- {namespace && namespace !== '_' && (
572
- <span>Namespace: <span className="text-theme-text-primary">{namespace}</span></span>
573
- )}
574
- {metadata.find(m => m.label === 'Image') && (
575
- <span className="truncate max-w-md font-mono text-xs">{metadata.find(m => m.label === 'Image')?.value}</span>
576
- )}
577
- {gitopsOwner && (
578
- <ManagedByChip owner={gitopsOwner} onOpen={onOpenGitOpsResource} variant="block" />
579
- )}
580
- {gitOpsResourcePath && onNavigateGitOpsPath && (
581
- <OpenInGitOpsChip onClick={() => onNavigateGitOpsPath(gitOpsResourcePath)} />
582
- )}
583
- {relationships?.owner && (
584
- <span>Owner: <button onClick={() => onNavigateToResource?.(refToSelectedResource(relationships.owner!))} className="text-blue-500 hover:underline">{relationships.owner.name}</button></span>
585
- )}
586
- </div>
626
+ )}
627
+ {namespace && namespace !== '_' && (
628
+ <span>Namespace: <span className="text-theme-text-primary">{namespace}</span></span>
629
+ )}
630
+ {metadata.find(m => m.label === 'Image') && (
631
+ <span className="truncate max-w-md font-mono text-xs">{metadata.find(m => m.label === 'Image')?.value}</span>
632
+ )}
633
+ {gitopsOwner && (
634
+ <ManagedByChip owner={gitopsOwner} status={gitOpsOwnerStatus} verified={gitOpsOwnerVerified} pending={gitOpsOwnerPending} source={gitOpsOwnerSource} onOpen={onOpenGitOpsResource} variant="block" />
635
+ )}
636
+ {helmOwner && (
637
+ <HelmManagedByChip owner={helmOwner} source={helmOwnerSource} onOpen={onOpenHelmRelease} variant="block" />
638
+ )}
639
+ {gitOpsResourcePath && onNavigateGitOpsPath && (
640
+ <OpenInGitOpsChip onClick={() => onNavigateGitOpsPath(gitOpsResourcePath)} />
641
+ )}
642
+ {relationships?.owner && (
643
+ <span>Owner: <button onClick={() => onNavigateToResource?.(refToSelectedResource(relationships.owner!))} className="text-blue-500 hover:underline">{relationships.owner.name}</button></span>
644
+ )}
587
645
  </div>
588
-
589
- {/* Refresh */}
646
+ </>
647
+ }
648
+ headerActions={
649
+ <>
590
650
  <button
591
651
  onClick={() => refetch()}
592
652
  disabled={isRefreshAnimating}
@@ -601,8 +661,6 @@ export function WorkloadView({
601
661
  : <RefreshCw className={clsx('w-5 h-5', refreshPhase === 'spinning' && 'animate-spin')} />
602
662
  }
603
663
  </button>
604
-
605
- {/* Collapse back to drawer */}
606
664
  {onCollapseToDrawer && (
607
665
  <button
608
666
  onClick={onCollapseToDrawer}
@@ -612,50 +670,15 @@ export function WorkloadView({
612
670
  <Minimize2 className="w-5 h-5" />
613
671
  </button>
614
672
  )}
615
- </div>
616
-
617
- {/* Tabs (left) + Actions (right) */}
618
- <div className="px-6 flex items-center border-t border-theme-border">
619
- <div className="flex gap-1">
620
- <TabButton active={activeTab === 'overview'} onClick={() => handleSetTab('overview')}>
621
- <Layers className="w-4 h-4" />
622
- Overview
623
- </TabButton>
624
- <TabButton active={activeTab === 'timeline'} onClick={() => handleSetTab('timeline')}>
625
- <Activity className="w-4 h-4" />
626
- Timeline
627
- {resourceEvents.length > 0 && (
628
- <span className="ml-1 badge-sm bg-theme-elevated">{resourceEvents.length}</span>
629
- )}
630
- </TabButton>
631
- {allPods.length > 0 && renderLogsTab && (
632
- <TabButton active={activeTab === 'logs'} onClick={() => handleSetTab('logs')}>
633
- <Terminal className="w-4 h-4" />
634
- Logs
635
- </TabButton>
636
- )}
637
- {showMetricsTab && renderMetricsTab && (
638
- <TabButton active={activeTab === 'metrics'} onClick={() => handleSetTab('metrics')}>
639
- <BarChart3 className="w-4 h-4" />
640
- Metrics
641
- </TabButton>
642
- )}
643
- <TabButton active={activeTab === 'yaml'} onClick={() => handleSetTab('yaml')}>
644
- <FileText className="w-4 h-4" />
645
- YAML
646
- </TabButton>
647
- </div>
648
- <div className="ml-auto">
649
- <ResourceActionsBar resource={selectedResource} data={resource} hideLogs {...actionsBarProps} />
650
- </div>
651
- </div>
652
- </div>
653
-
654
- {/* Success animation overlay */}
655
- {saveSuccess && <SaveSuccessAnimation />}
656
-
657
- {/* Tab Content */}
658
- <div className="flex-1 overflow-hidden relative">
673
+ </>
674
+ }
675
+ tabs={tabs}
676
+ activeTab={activeTab}
677
+ onTabChange={handleSetTab}
678
+ scopeControls={scopeControls}
679
+ tabStripEnd={<ResourceActionsBar resource={selectedResource} data={resource} hideLogs {...actionsBarProps} />}
680
+ overlay={saveSuccess ? <SaveSuccessAnimation /> : null}
681
+ >
659
682
  {activeTab === 'overview' && (
660
683
  <InfoTab
661
684
  resource={resource}
@@ -732,8 +755,7 @@ export function WorkloadView({
732
755
  )}
733
756
  </div>
734
757
  )}
735
- </div>
736
- </div>
758
+ </DetailShell>
737
759
  )
738
760
  }
739
761
 
@@ -792,22 +814,6 @@ function OpenInGitOpsChip({ onClick }: { onClick: () => void }) {
792
814
  )
793
815
  }
794
816
 
795
- function TabButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
796
- return (
797
- <button
798
- onClick={onClick}
799
- className={clsx(
800
- 'flex items-center gap-1.5 px-3 py-2 text-sm font-medium border-b-2 transition-colors',
801
- active
802
- ? 'text-theme-text-primary border-skyhook-500'
803
- : 'text-theme-text-secondary border-transparent hover:text-theme-text-primary hover:border-theme-border-light'
804
- )}
805
- >
806
- {children}
807
- </button>
808
- )
809
- }
810
-
811
817
  // ============================================================================
812
818
  // EVENTS TAB (Swimlane timeline)
813
819
  // ============================================================================
package/src/index.ts CHANGED
@@ -45,6 +45,11 @@ export * from './components/audit'
45
45
  // resolve.
46
46
  export * from './components/checks'
47
47
 
48
+ // Live issues queue (IssuesView — the grouped operational-issue triage queue,
49
+ // shared by OSS single-cluster and the hub fleet view; sibling to the Checks
50
+ // queue)
51
+ export * from './components/issues'
52
+
48
53
  // Cluster switcher (shared trigger+dropdown for OSS Radar and Radar Hub)
49
54
  export * from './components/cluster-switcher'
50
55
 
@@ -65,6 +65,22 @@
65
65
  box-shadow: var(--shadow-lg);
66
66
  }
67
67
 
68
+ /* ── INLINE CODE ── */
69
+
70
+ .inline-code {
71
+ -webkit-box-decoration-break: clone;
72
+ box-decoration-break: clone;
73
+ border: 0.5px solid color-mix(in srgb, var(--color-brand) 24%, var(--border-default));
74
+ border-radius: 0.25rem;
75
+ background-color: color-mix(in srgb, var(--color-brand) 8%, var(--bg-elevated));
76
+ padding: 0.0625rem 0.3125rem;
77
+ font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace);
78
+ font-size: 0.9em;
79
+ font-weight: 500;
80
+ line-height: 1.5;
81
+ color: var(--text-primary);
82
+ }
83
+
68
84
  /* ── BADGES ── */
69
85
 
70
86
  .badge {
@@ -110,6 +126,25 @@
110
126
  padding: 0.75rem;
111
127
  }
112
128
 
129
+ /* ── ISSUE ROW DETAILS ── */
130
+
131
+ .issue-details-motion {
132
+ display: grid;
133
+ grid-template-rows: 0fr;
134
+ overflow: hidden;
135
+ transition: grid-template-rows 200ms ease-out;
136
+ }
137
+
138
+ .issue-details-motion-open {
139
+ grid-template-rows: 1fr;
140
+ }
141
+
142
+ @starting-style {
143
+ .issue-details-motion-open {
144
+ grid-template-rows: 0fr;
145
+ }
146
+ }
147
+
113
148
  /* ── SUBTLE BORDERS ── */
114
149
 
115
150
  .table-divide-subtle > * + * {
package/src/types/core.ts CHANGED
@@ -411,7 +411,8 @@ export interface ResolvedEnvFromEntry {
411
411
  values: Record<string, string>
412
412
  isSecret: boolean
413
413
  }
414
- export type ResolvedEnvFrom = Record<string, ResolvedEnvFromEntry>
414
+ export type ResolvedEnvFromKey = `configmap:${string}` | `secret:${string}`
415
+ export type ResolvedEnvFrom = Partial<Record<ResolvedEnvFromKey, ResolvedEnvFromEntry>>
415
416
 
416
417
  // Resource reference (for relationships)
417
418
  export interface ResourceRef {
@@ -939,7 +940,7 @@ export interface TrafficFilters {
939
940
  // Library consumers (Radar Hub) get all GitOps surfaces — the package
940
941
  // IS the public surface, so adding new top-level views must extend
941
942
  // this type rather than rely on app-local extensions.
942
- export type ExtendedMainView = MainView | 'traffic' | 'cost' | 'audit' | 'gitops'
943
+ export type ExtendedMainView = MainView | 'traffic' | 'cost' | 'audit' | 'gitops' | 'issues'
943
944
 
944
945
  // ============================================================================
945
946
  // Image Filesystem Types
@@ -0,0 +1,3 @@
1
+ export function resolvedEnvFromKey(kind: 'configmap' | 'secret', name: string) {
2
+ return `${kind}:${name}` as const
3
+ }
@@ -8,6 +8,7 @@ export * from './animation'
8
8
  export * from './resource-hierarchy'
9
9
  export * from './log-format'
10
10
  export * from './download'
11
+ export * from './env-from'
11
12
  export * from './api-resources'
12
13
  export * from './skeleton-yaml'
13
14
  export * from './k8s-errors'
@@ -0,0 +1,9 @@
1
+ import type { ResourceRef } from '../types'
2
+
3
+ export function replicaScalers(scalers?: ResourceRef[]): ResourceRef[] | undefined {
4
+ const result = scalers?.filter((ref) => {
5
+ const kind = ref.kind.toLowerCase()
6
+ return kind === 'horizontalpodautoscaler' || kind === 'scaledobject'
7
+ })
8
+ return result && result.length > 0 ? result : undefined
9
+ }
@@ -1,85 +0,0 @@
1
- import { describe, it, expect } from 'vitest'
2
- import { pluralize } from '../../utils/pluralize'
3
- import type { SelectedKindInfo } from './ResourcesSidebar'
4
-
5
- // Mirrors the empty-state hint rule inside ResourcesView (kept in
6
- // sync by reuse) — pure so we can pin the lookup + render contract
7
- // without rendering the React component. See
8
- // `packages/k8s-ui/src/components/resources/ResourcesView.tsx`
9
- // search for "the count is unfiltered".
10
- function sidebarHint(
11
- counts: Record<string, number>,
12
- selectedKind: SelectedKindInfo,
13
- searchTerm: string,
14
- ): string | null {
15
- if (!searchTerm) return null
16
- const key = selectedKind.group ? `${selectedKind.group}/${selectedKind.kind}` : selectedKind.kind
17
- const totalForKind = counts[key] ?? 0
18
- if (totalForKind === 0) return null
19
- return `The sidebar shows ${pluralize(totalForKind, selectedKind.kind)} in the cluster — the count is unfiltered.`
20
- }
21
-
22
- describe('ResourcesView empty-search sidebar hint (SKY-828 bug 46)', () => {
23
- // The bug: sidebar count badge stays unfiltered and shows the
24
- // cluster-wide total per kind. When a search returns zero rows
25
- // (e.g. "5 Pods" in sidebar, "No Pods found" in pane) the user
26
- // reads the badge as a lie. The empty-state appends a sentence
27
- // making the unfiltered nature explicit. The rule must:
28
- // 1. Suppress entirely when there's no active search.
29
- // 2. Suppress when the cluster total for the kind is 0
30
- // (no badge to explain → no hint).
31
- // 3. Look up the count under `${group}/${kind}` for grouped
32
- // kinds, plain `kind` for core kinds (matches sidebar key).
33
- // 4. Pluralize via pluralize() so "Ingress" → "Ingresses",
34
- // "NetworkPolicy" → "NetworkPolicies" (regression of the
35
- // naive `${kind}s` Cursor Bugbot caught in 80eb64b).
36
- // 5. Singular for n===1: "1 Pod", not "1 Pods".
37
-
38
- const pod: SelectedKindInfo = { name: 'pods', kind: 'Pod', group: '' }
39
-
40
- it('returns null when there is no active search', () => {
41
- expect(sidebarHint({ Pod: 232 }, pod, '')).toBeNull()
42
- })
43
-
44
- it('returns null when the cluster total for the kind is 0', () => {
45
- expect(sidebarHint({ Pod: 0 }, pod, 'xyz')).toBeNull()
46
- })
47
-
48
- it('returns null when the kind is missing from counts', () => {
49
- expect(sidebarHint({}, pod, 'xyz')).toBeNull()
50
- })
51
-
52
- it('formats the sentence with the cluster total and pluralized kind', () => {
53
- expect(sidebarHint({ Pod: 232 }, pod, 'xyz')).toBe(
54
- 'The sidebar shows 232 Pods in the cluster — the count is unfiltered.',
55
- )
56
- })
57
-
58
- it('uses singular noun when total is 1', () => {
59
- expect(sidebarHint({ Pod: 1 }, pod, 'xyz')).toBe(
60
- 'The sidebar shows 1 Pod in the cluster — the count is unfiltered.',
61
- )
62
- })
63
-
64
- it('looks up grouped kinds under `${group}/${kind}`', () => {
65
- const ar: SelectedKindInfo = { name: 'rollouts', kind: 'Rollout', group: 'argoproj.io' }
66
- const counts = { Rollout: 999, 'argoproj.io/Rollout': 4 }
67
- expect(sidebarHint(counts, ar, 'xyz')).toBe(
68
- 'The sidebar shows 4 Rollouts in the cluster — the count is unfiltered.',
69
- )
70
- })
71
-
72
- it('pluralizes Ingress correctly (regression: not "Ingresss")', () => {
73
- const ing: SelectedKindInfo = { name: 'ingresses', kind: 'Ingress', group: '' }
74
- expect(sidebarHint({ Ingress: 3 }, ing, 'xyz')).toBe(
75
- 'The sidebar shows 3 Ingresses in the cluster — the count is unfiltered.',
76
- )
77
- })
78
-
79
- it('pluralizes NetworkPolicy correctly (regression: not "NetworkPolicys")', () => {
80
- const np: SelectedKindInfo = { name: 'networkpolicies', kind: 'NetworkPolicy', group: 'networking.k8s.io' }
81
- expect(sidebarHint({ 'networking.k8s.io/NetworkPolicy': 2 }, np, 'xyz')).toBe(
82
- 'The sidebar shows 2 NetworkPolicies in the cluster — the count is unfiltered.',
83
- )
84
- })
85
- })