@skyhook-io/k8s-ui 1.7.6 → 1.7.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.
Files changed (56) 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/GitOpsDetailLayout.tsx +4 -4
  6. package/src/components/gitops/GitOpsTableView.tsx +203 -6
  7. package/src/components/gitops/index.ts +1 -0
  8. package/src/components/gitops/insights/GitOpsInsightViews.tsx +3 -3
  9. package/src/components/issues/IssuesView.tsx +352 -0
  10. package/src/components/issues/index.ts +24 -0
  11. package/src/components/issues/issues.test.ts +100 -0
  12. package/src/components/issues/severity.ts +125 -0
  13. package/src/components/issues/types.ts +194 -0
  14. package/src/components/resources/ResourcesView.tsx +27 -25
  15. package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +47 -3
  16. package/src/components/resources/renderers/CAPIKubeadmControlPlaneRenderer.tsx +1 -1
  17. package/src/components/resources/renderers/CAPIMachineDeploymentRenderer.tsx +1 -1
  18. package/src/components/resources/renderers/CAPIMachineSetRenderer.tsx +1 -1
  19. package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +1 -1
  20. package/src/components/resources/renderers/CompositionRenderer.tsx +3 -3
  21. package/src/components/resources/renderers/CrossplanePackageRenderer.tsx +5 -5
  22. package/src/components/resources/renderers/CrossplaneProviderConfigRenderer.tsx +1 -1
  23. package/src/components/resources/renderers/KnativeSourceRenderer.tsx +1 -1
  24. package/src/components/resources/renderers/PodRenderer.test.tsx +53 -0
  25. package/src/components/resources/renderers/PodRenderer.tsx +7 -1
  26. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +1 -1
  27. package/src/components/resources/renderers/WorkloadRenderer.test.tsx +41 -0
  28. package/src/components/resources/renderers/WorkloadRenderer.tsx +49 -7
  29. package/src/components/resources/renderers/XRDRenderer.tsx +2 -2
  30. package/src/components/resources/resource-utils-argo.ts +18 -0
  31. package/src/components/shared/DetailShell.tsx +107 -0
  32. package/src/components/shared/ManagedByChip.tsx +149 -16
  33. package/src/components/shared/ResourceRendererDispatch.test.tsx +45 -0
  34. package/src/components/shared/ResourceRendererDispatch.tsx +13 -1
  35. package/src/components/shared/index.ts +2 -1
  36. package/src/components/timeline/TimelineSwimlanes.tsx +1320 -0
  37. package/src/components/timeline/index.ts +1 -0
  38. package/src/components/topology/K8sResourceNode.tsx +60 -60
  39. package/src/components/topology/TopologyFilterSidebar.tsx +25 -3
  40. package/src/components/topology/TopologyGraph.tsx +168 -52
  41. package/src/components/topology/TopologySearch.tsx +17 -8
  42. package/src/components/topology/topology-search-match.test.ts +1 -1
  43. package/src/components/topology/topology.css +18 -0
  44. package/src/components/ui/RowActionMenu.tsx +149 -0
  45. package/src/components/ui/Tooltip.tsx +37 -2
  46. package/src/components/ui/index.ts +2 -0
  47. package/src/components/workload/WorkloadView.tsx +118 -112
  48. package/src/index.ts +5 -0
  49. package/src/theme/components.css +16 -0
  50. package/src/types/core.ts +3 -2
  51. package/src/utils/env-from.ts +3 -0
  52. package/src/utils/git-provider-urls.test.ts +348 -0
  53. package/src/utils/git-provider-urls.ts +142 -0
  54. package/src/utils/index.ts +2 -0
  55. package/src/utils/replica-scalers.ts +9 -0
  56. package/src/components/resources/resources-search-sidebar-hint.test.ts +0 -85
@@ -0,0 +1,149 @@
1
+ import { Fragment, useEffect, useLayoutEffect, useRef, useState, type ComponentType } from 'react'
2
+ import { Loader2, MoreVertical } from 'lucide-react'
3
+ import { clsx } from 'clsx'
4
+ import { Tooltip } from './Tooltip'
5
+
6
+ export interface RowActionItem {
7
+ key: string
8
+ label: string
9
+ icon: ComponentType<{ className?: string }>
10
+ onClick: () => void
11
+ disabled?: boolean
12
+ disabledReason?: string
13
+ pending?: boolean
14
+ danger?: boolean
15
+ /** Render a horizontal divider above this item. */
16
+ divider?: boolean
17
+ }
18
+
19
+ interface RowActionMenuProps {
20
+ items: RowActionItem[]
21
+ ariaLabel?: string
22
+ /** Compact button variant (default: true) — sized for table-row anchoring. */
23
+ compact?: boolean
24
+ }
25
+
26
+ export function RowActionMenu({ items, ariaLabel = 'Row actions', compact = true }: RowActionMenuProps) {
27
+ const [open, setOpen] = useState(false)
28
+ // Flip the menu above the trigger when it would otherwise spill past the
29
+ // viewport bottom. The GitOps table's bottom rows sit at the end of a scroll
30
+ // container with the app's fixed overlay buttons below them, so a
31
+ // downward-opening menu there clips its lowest items with no way to scroll
32
+ // them into view. Measured after open (useLayoutEffect, pre-paint, no flicker).
33
+ const [openUp, setOpenUp] = useState(false)
34
+ const ref = useRef<HTMLDivElement>(null)
35
+ const menuRef = useRef<HTMLDivElement>(null)
36
+
37
+ useLayoutEffect(() => {
38
+ if (!open) {
39
+ setOpenUp(false)
40
+ return
41
+ }
42
+ const trigger = ref.current?.getBoundingClientRect()
43
+ const menuH = menuRef.current?.offsetHeight ?? 0
44
+ if (!trigger) return
45
+ const spaceBelow = window.innerHeight - trigger.bottom
46
+ // Flip up only when there's not enough room below AND enough room above,
47
+ // so a tall menu near the top doesn't get clipped at the other end.
48
+ setOpenUp(menuH + 8 > spaceBelow && trigger.top > menuH + 8)
49
+ }, [open])
50
+
51
+ useEffect(() => {
52
+ if (!open) return
53
+ const onDown = (e: MouseEvent) => {
54
+ if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
55
+ }
56
+ const onKey = (e: KeyboardEvent) => {
57
+ if (e.key === 'Escape') setOpen(false)
58
+ }
59
+ document.addEventListener('mousedown', onDown)
60
+ document.addEventListener('keydown', onKey)
61
+ return () => {
62
+ document.removeEventListener('mousedown', onDown)
63
+ document.removeEventListener('keydown', onKey)
64
+ }
65
+ }, [open])
66
+
67
+ const triggerSize = compact ? 'p-1' : 'p-1.5'
68
+ const iconSize = compact ? 'h-4 w-4' : 'h-5 w-5'
69
+
70
+ return (
71
+ <div ref={ref} className="relative inline-block">
72
+ <button
73
+ type="button"
74
+ aria-label={ariaLabel}
75
+ aria-haspopup="menu"
76
+ aria-expanded={open}
77
+ onClick={(e) => {
78
+ e.stopPropagation()
79
+ setOpen((v) => !v)
80
+ }}
81
+ className={clsx(
82
+ 'rounded text-theme-text-tertiary hover:bg-theme-hover hover:text-theme-text-primary',
83
+ triggerSize,
84
+ )}
85
+ >
86
+ <MoreVertical className={iconSize} />
87
+ </button>
88
+ {open && (
89
+ <div
90
+ ref={menuRef}
91
+ role="menu"
92
+ className={clsx(
93
+ 'absolute right-0 z-50 min-w-[180px] rounded-lg border border-theme-border bg-theme-surface py-1 shadow-xl',
94
+ openUp ? 'bottom-full mb-1' : 'top-full mt-1',
95
+ )}
96
+ onClick={(e) => e.stopPropagation()}
97
+ >
98
+ {items.map((item) => {
99
+ const Icon = item.icon
100
+ const content = (
101
+ <button
102
+ type="button"
103
+ role="menuitem"
104
+ disabled={item.disabled || item.pending}
105
+ onClick={(e) => {
106
+ e.stopPropagation()
107
+ if (item.disabled || item.pending) return
108
+ item.onClick()
109
+ setOpen(false)
110
+ }}
111
+ className={clsx(
112
+ 'flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs transition-colors',
113
+ item.disabled || item.pending
114
+ ? 'cursor-not-allowed text-theme-text-tertiary'
115
+ : item.danger
116
+ ? 'text-red-500 hover:bg-theme-hover hover:text-red-400'
117
+ : 'text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary',
118
+ )}
119
+ >
120
+ {item.pending ? (
121
+ <Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin" />
122
+ ) : (
123
+ <Icon className="h-3.5 w-3.5 shrink-0" />
124
+ )}
125
+ <span className="truncate">{item.label}</span>
126
+ </button>
127
+ )
128
+ return (
129
+ <Fragment key={item.key}>
130
+ {item.divider && <div className="my-1 h-px bg-theme-border" />}
131
+ {item.disabled && item.disabledReason ? (
132
+ // wrapperClassName=w-full so the disabled item fills the menu
133
+ // like enabled items — the Tooltip wrapper is inline-flex and
134
+ // would otherwise shrink-wrap, and the menu inherits text-right
135
+ // from the table's actions cell, shoving the item to the edge.
136
+ <Tooltip content={item.disabledReason} position="left" wrapperClassName="w-full">
137
+ {content}
138
+ </Tooltip>
139
+ ) : (
140
+ content
141
+ )}
142
+ </Fragment>
143
+ )
144
+ })}
145
+ </div>
146
+ )}
147
+ </div>
148
+ )
149
+ }
@@ -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>,
@@ -20,3 +20,5 @@ export { ForceDeleteConfirmDialog } from './ForceDeleteConfirmDialog'
20
20
  export { ToastProvider, useToast, showApiError, showApiSuccess } from './Toast'
21
21
  export { CodeViewer } from './CodeViewer'
22
22
  export { YamlEditor, YamlDiffEditor } from './YamlEditor'
23
+ export { RowActionMenu } from './RowActionMenu'
24
+ export type { RowActionItem } from './RowActionMenu'
@@ -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 {
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
+ }