@skyhook-io/k8s-ui 1.5.13 → 1.6.1

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 (57) hide show
  1. package/package.json +4 -4
  2. package/src/components/cluster-switcher/ClusterSwitcher.tsx +2 -3
  3. package/src/components/dock/BottomDock.tsx +24 -17
  4. package/src/components/dock/DockContext.tsx +39 -0
  5. package/src/components/gitops/GitOpsDetailLayout.tsx +621 -0
  6. package/src/components/gitops/GitOpsGraphFilterRail.tsx +216 -0
  7. package/src/components/gitops/GitOpsTableView.tsx +1441 -0
  8. package/src/components/gitops/RollbackDialog.tsx +117 -0
  9. package/src/components/gitops/SyncOptionsDialog.tsx +160 -0
  10. package/src/components/gitops/detail-helpers.test.ts +97 -0
  11. package/src/components/gitops/detail-helpers.ts +112 -0
  12. package/src/components/gitops/index.ts +55 -0
  13. package/src/components/gitops/insights/GitOpsInsightViews.tsx +1456 -0
  14. package/src/components/gitops/insights/index.ts +6 -0
  15. package/src/components/gitops/insights/insights-helpers.test.ts +98 -0
  16. package/src/components/gitops/insights/insights-helpers.ts +99 -0
  17. package/src/components/gitops/short-cluster-name.test.ts +36 -0
  18. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +799 -0
  19. package/src/components/gitops/tree/index.ts +6 -0
  20. package/src/components/gitops/tree/merge.test.ts +240 -0
  21. package/src/components/gitops/tree/merge.ts +160 -0
  22. package/src/components/gitops/tree/tree-helpers.ts +42 -0
  23. package/src/components/resources/ResourcesSidebar.tsx +42 -15
  24. package/src/components/resources/ResourcesView.tsx +136 -30
  25. package/src/components/resources/index.ts +1 -1
  26. package/src/components/resources/renderers/KnativeConfigurationRenderer.tsx +1 -1
  27. package/src/components/resources/renderers/KnativeRevisionRenderer.tsx +1 -1
  28. package/src/components/resources/renderers/KnativeServiceRenderer.tsx +1 -1
  29. package/src/components/resources/renderers/PodRenderer.tsx +4 -3
  30. package/src/components/resources/renderers/SecretRenderer.tsx +4 -10
  31. package/src/components/shared/EditableYamlView.tsx +28 -17
  32. package/src/components/shared/ManagedByChip.tsx +45 -0
  33. package/src/components/shared/index.ts +1 -0
  34. package/src/components/timeline/TimelineList.tsx +3 -3
  35. package/src/components/topology/TopologyGraph.tsx +3 -2
  36. package/src/components/ui/Tooltip.tsx +10 -1
  37. package/src/components/ui/drawer-components.tsx +9 -21
  38. package/src/components/workload/ResourceDetailDrawer.tsx +5 -3
  39. package/src/components/workload/WorkloadView.tsx +66 -0
  40. package/src/hooks/useKeyboardShortcuts.tsx +3 -2
  41. package/src/index.ts +3 -0
  42. package/src/types/core.ts +48 -6
  43. package/src/types/gitops-insights.ts +193 -0
  44. package/src/types/gitops-tree.ts +57 -0
  45. package/src/types/index.ts +2 -0
  46. package/src/utils/badge-colors.ts +31 -1
  47. package/src/utils/format.ts +28 -0
  48. package/src/utils/gitops-owner.test.ts +95 -0
  49. package/src/utils/gitops-owner.ts +55 -0
  50. package/src/utils/gitops-route.test.ts +78 -0
  51. package/src/utils/gitops-route.ts +104 -0
  52. package/src/utils/helm-status.test.ts +50 -0
  53. package/src/utils/index.ts +2 -0
  54. package/src/utils/navigation.ts +14 -0
  55. package/src/utils/resource-hierarchy.ts +47 -3
  56. package/src/utils/yaml.test.ts +101 -0
  57. package/src/utils/yaml.ts +26 -0
@@ -1655,6 +1655,8 @@ interface ResourcesViewProps {
1655
1655
  hideSidebar?: boolean
1656
1656
  /** Callback when the [+] create button is clicked. Receives the currently selected kind info. */
1657
1657
  onCreateResource?: (kind: { name: string; kind: string; group: string } | null) => void
1658
+ /** Default kind when the URL does not include one. */
1659
+ defaultKind?: SelectedKindInfo
1658
1660
  /** Columns prepended to KNOWN_COLUMNS for every kind. For example, a
1659
1661
  * multi-cluster host can inject a leading Cluster column. Each extra
1660
1662
  * column is self-contained (own render/sort/filter), so the host
@@ -1692,6 +1694,7 @@ const DEFAULT_KIND_INFO: SelectedKindInfo = { name: 'pods', kind: 'Pod', group:
1692
1694
  // fall through to DEFAULT_KIND.
1693
1695
  function getInitialKindFromURL(
1694
1696
  basePath: string = '/resources',
1697
+ defaultKind: SelectedKindInfo = DEFAULT_KIND_INFO,
1695
1698
  locationPathname?: string,
1696
1699
  locationSearch?: string,
1697
1700
  ): SelectedKindInfo {
@@ -1726,7 +1729,7 @@ function getInitialKindFromURL(
1726
1729
  }
1727
1730
  return { name: kind, kind: kind, group }
1728
1731
  }
1729
- return DEFAULT_KIND_INFO
1732
+ return defaultKind
1730
1733
  }
1731
1734
 
1732
1735
  // Get initial filters from URL
@@ -1749,6 +1752,51 @@ function getInitialFiltersFromURL() {
1749
1752
  // Sort state type
1750
1753
  type SortDirection = 'asc' | 'desc' | null
1751
1754
 
1755
+ // Coarse "just now / Xm / Xh / Xd" buckets — finer-grained updates
1756
+ // add motion in the periphery without aiding any user decision.
1757
+ function formatLastUpdatedBucket(elapsedMs: number): string {
1758
+ const elapsedSec = Math.max(0, Math.floor(elapsedMs / 1000))
1759
+ if (elapsedSec < 60) return 'just now'
1760
+ const minutes = Math.floor(elapsedSec / 60)
1761
+ if (minutes < 60) return `${minutes}m`
1762
+ const hours = Math.floor(minutes / 60)
1763
+ if (hours < 24) return `${hours}h`
1764
+ return `${Math.floor(hours / 24)}d`
1765
+ }
1766
+
1767
+ // ms until the displayed bucket would change.
1768
+ function msToNextBucket(elapsedMs: number): number {
1769
+ const elapsed = Math.max(0, elapsedMs)
1770
+ if (elapsed < 60_000) return 60_000 - elapsed
1771
+ if (elapsed < 3_600_000) return 60_000 - (elapsed % 60_000)
1772
+ if (elapsed < 86_400_000) return 3_600_000 - (elapsed % 3_600_000)
1773
+ return 86_400_000 - (elapsed % 86_400_000)
1774
+ }
1775
+
1776
+ // Isolated subtree so re-renders don't cascade into the parent's
1777
+ // virtualized table.
1778
+ function LastUpdatedLabel({ lastUpdated }: { lastUpdated: Date }) {
1779
+ const [, force] = useState(0)
1780
+ useEffect(() => {
1781
+ let id: ReturnType<typeof setTimeout>
1782
+ function schedule() {
1783
+ const delay = Math.max(1000, msToNextBucket(Date.now() - lastUpdated.getTime()))
1784
+ id = setTimeout(() => {
1785
+ force(t => t + 1)
1786
+ schedule()
1787
+ }, delay)
1788
+ }
1789
+ schedule()
1790
+ return () => clearTimeout(id)
1791
+ }, [lastUpdated])
1792
+ return (
1793
+ <div className="flex items-center gap-1.5 text-xs text-theme-text-tertiary">
1794
+ <Clock className="w-3.5 h-3.5" />
1795
+ <span>Updated {formatLastUpdatedBucket(Date.now() - lastUpdated.getTime())}</span>
1796
+ </div>
1797
+ )
1798
+ }
1799
+
1752
1800
  export function ResourcesView({
1753
1801
  namespaces, selectedResource, onResourceClick, onResourceClickYaml, onKindChange,
1754
1802
  apiResources: apiResourcesProp,
@@ -1772,18 +1820,23 @@ export function ResourcesView({
1772
1820
  onSelectedKindChange,
1773
1821
  hideSidebar = false,
1774
1822
  onCreateResource,
1823
+ defaultKind = DEFAULT_KIND_INFO,
1775
1824
  extraLeadingColumns,
1776
1825
  onRowSelect,
1777
1826
  }: ResourcesViewProps) {
1778
1827
  const initialFilters = getInitialFiltersFromURL()
1779
- const [selectedKind, setSelectedKind] = useState<SelectedKindInfo>(() => getInitialKindFromURL(basePath, locationPathname, locationSearch))
1780
- // Sync selectedKind from URL when locationPathname changes (e.g., browser back, external sidebar navigation)
1828
+ const [selectedKind, setSelectedKind] = useState<SelectedKindInfo>(() => getInitialKindFromURL(basePath, defaultKind, locationPathname, locationSearch))
1829
+ // Sync selectedKind from URL when the URL changes (browser back, external sidebar navigation).
1830
+ // Deps are URL-derived only — including selectedKind.name/group would race against pending
1831
+ // navigation: a sidebar click flips state before navigate() lands, this effect re-reads the
1832
+ // stale URL, and reverts the kind. The window into a stale URL between state change and URL
1833
+ // update is what produced the "blink and fail to navigate" bug.
1781
1834
  useEffect(() => {
1782
- const kindFromURL = getInitialKindFromURL(basePath, locationPathname, locationSearch)
1783
- if (kindFromURL.name !== selectedKind.name || kindFromURL.group !== selectedKind.group) {
1784
- setSelectedKind(kindFromURL)
1785
- }
1786
- }, [locationPathname, locationSearch]) // eslint-disable-line react-hooks/exhaustive-deps
1835
+ const kindFromURL = getInitialKindFromURL(basePath, defaultKind, locationPathname, locationSearch)
1836
+ setSelectedKind((prev) =>
1837
+ kindFromURL.name !== prev.name || kindFromURL.group !== prev.group ? kindFromURL : prev,
1838
+ )
1839
+ }, [basePath, defaultKind, locationPathname, locationSearch])
1787
1840
  // Notify parent of selected kind changes (including initial mount)
1788
1841
  useEffect(() => {
1789
1842
  onSelectedKindChange?.(selectedKind)
@@ -1850,6 +1903,9 @@ export function ResourcesView({
1850
1903
  const hasProcessedInitialResource = useRef(false)
1851
1904
  // Set by sidebar kind change to push a browser history entry (vs replace for filter changes)
1852
1905
  const shouldPushHistory = useRef(false)
1906
+ // Used by the URL-write effect to distinguish drawer-to-drawer navigation (A -> B, push)
1907
+ // from initial open (null -> X) and close (X -> null), which stay as URL replaces.
1908
+ const prevSelectedResourceRef = useRef<SelectedResource | null>(null)
1853
1909
 
1854
1910
  // Ref to search input for keyboard shortcut
1855
1911
  const searchInputRef = useRef<HTMLInputElement>(null)
@@ -2336,7 +2392,7 @@ export function ResourcesView({
2336
2392
  isSyncingFromURL.current = true
2337
2393
 
2338
2394
  // Re-read URL params and update state
2339
- const newKind = getInitialKindFromURL(basePath, locationPathname, locationSearch)
2395
+ const newKind = getInitialKindFromURL(basePath, defaultKind, locationPathname, locationSearch)
2340
2396
  const newFilters = getInitialFiltersFromURL()
2341
2397
 
2342
2398
  // Update kind if it changed
@@ -2366,7 +2422,7 @@ export function ResourcesView({
2366
2422
  requestAnimationFrame(() => {
2367
2423
  isSyncingFromURL.current = false
2368
2424
  })
2369
- }, [locationPathname, locationSearch]) // Re-run when injected URL path or search params change
2425
+ }, [locationPathname, locationSearch, defaultKind, basePath]) // Re-run when injected URL path or search params change
2370
2426
 
2371
2427
  const navigate = useMemo(() => {
2372
2428
  if (!onNavigate) return (_pathOrObj: any, _opts?: any) => {}
@@ -2434,6 +2490,25 @@ export function ResourcesView({
2434
2490
  const newPath = `${basePath}/${kindInfo.name}`
2435
2491
  const queryStr = params.toString()
2436
2492
 
2493
+ // No-op guard: if the target URL already matches the address bar, skip the
2494
+ // navigate. Without this, a state catch-up after browser POP (App-level
2495
+ // POP→state sync re-running this effect) would push a duplicate entry on
2496
+ // top of the popped state — making the next Back appear to do nothing or
2497
+ // (with multi-namespace name collisions) jump to a sibling resource via
2498
+ // auto-resolution. Reading window.location avoids needing host-injected
2499
+ // navigationType.
2500
+ if (typeof window !== 'undefined') {
2501
+ const currentPathname = window.location.pathname
2502
+ const currentSearch = window.location.search.replace(/^\?/, '')
2503
+ // Compare using basename-relative target path against window.pathname,
2504
+ // which may include a host basename (e.g. /c/{cluster}). Treat a path
2505
+ // suffix match as equal so embedded hosts don't false-trigger a write.
2506
+ const pathMatches = currentPathname === newPath || currentPathname.endsWith(newPath)
2507
+ if (pathMatches && currentSearch === queryStr) {
2508
+ return
2509
+ }
2510
+ }
2511
+
2437
2512
  // Route both push and replace through `navigate` (which honors the
2438
2513
  // onNavigate prop). The previous direct `window.history.replaceState`
2439
2514
  // bypass meant a host that wants to suppress URL writes (passing
@@ -2446,12 +2521,12 @@ export function ResourcesView({
2446
2521
  useEffect(() => {
2447
2522
  // Skip URL update if we're syncing FROM the URL (e.g., browser back button)
2448
2523
  if (isSyncingFromURL.current) {
2449
-
2524
+ prevSelectedResourceRef.current = selectedResource ?? null
2450
2525
  return
2451
2526
  }
2452
2527
  // Skip on initial mount so we don't strip ?resource= before the mount effect reads it
2453
2528
  if (!hasProcessedInitialResource.current) {
2454
-
2529
+ prevSelectedResourceRef.current = selectedResource ?? null
2455
2530
  return
2456
2531
  }
2457
2532
  // Skip URL update if selectedResource's kind doesn't match selectedKind (still syncing)
@@ -2462,12 +2537,38 @@ export function ResourcesView({
2462
2537
  return // Wait for kind sync effect to run first
2463
2538
  }
2464
2539
  }
2465
- // Push history when kind changes (so browser back/forward works), replace for filter changes
2466
- const pushHistory = shouldPushHistory.current
2540
+ // Push history for navigations (so browser back works); replace for filter / drawer-toggle changes.
2541
+ // A navigation is one of: explicit sidebar/keyboard kind switch (shouldPushHistory),
2542
+ // kind change driven by external setSelectedResource (pathname differs from target — e.g. clicking a
2543
+ // Parent Gateway from a TCPRoute drawer), or a drawer-to-drawer switch within the same kind
2544
+ // (selectedResource A -> B, both non-null and different). Initial open (null -> X) and close (X -> null)
2545
+ // stay as replace because they don't represent a destination the user wants to "go back" to.
2546
+ const targetPath = `${basePath}/${selectedKind.name}`
2547
+ // Compare basename-relative paths. Hosts that mount the app under a non-empty basename
2548
+ // (e.g. Radar Hub at /c/{cluster}) inject `locationPathname` from useLocation(), which strips
2549
+ // the basename — `window.location.pathname` still includes it, so reading window directly
2550
+ // would never match `targetPath` (basename-relative) and force every URL write to push.
2551
+ const currentPath =
2552
+ locationPathname !== undefined
2553
+ ? locationPathname
2554
+ : typeof window !== 'undefined'
2555
+ ? window.location.pathname
2556
+ : ''
2557
+ const pathChanged = currentPath !== targetPath
2558
+ const prev = prevSelectedResourceRef.current
2559
+ const current = selectedResource ?? null
2560
+ const drawerSwitched =
2561
+ prev !== null && current !== null &&
2562
+ (prev.namespace !== current.namespace ||
2563
+ prev.name !== current.name ||
2564
+ prev.kind !== current.kind ||
2565
+ (prev.group ?? '') !== (current.group ?? ''))
2566
+ const pushHistory = shouldPushHistory.current || pathChanged || drawerSwitched
2467
2567
  shouldPushHistory.current = false
2568
+ prevSelectedResourceRef.current = current
2468
2569
 
2469
2570
  updateURL(selectedKind, searchTerm, columnFilters, problemFilters, showInactiveReplicaSets, selectedResource?.namespace, selectedResource?.name, pushHistory)
2470
- }, [selectedKind, searchTerm, columnFilters, problemFilters, showInactiveReplicaSets, selectedResource, updateURL])
2571
+ }, [selectedKind, searchTerm, columnFilters, problemFilters, showInactiveReplicaSets, selectedResource, updateURL, basePath, locationPathname])
2471
2572
 
2472
2573
  // Handle resource click from URL on mount
2473
2574
  useEffect(() => {
@@ -2624,12 +2725,18 @@ export function ResourcesView({
2624
2725
 
2625
2726
  const [refetch, isRefreshAnimating, refreshPhase] = useRefreshAnimation(() => refetchFn?.())
2626
2727
 
2627
- // Track last updated time
2728
+ // React Query bumps dataUpdatedAt on no-op refetches (window focus,
2729
+ // mount, sibling subscribers); structural sharing returns the same
2730
+ // resources reference when data is byte-identical. Skip the timer
2731
+ // reset in that case — otherwise opening a filter drawer looks like
2732
+ // it triggered a real fetch.
2733
+ const lastDataRef = useRef<unknown>(undefined)
2628
2734
  useEffect(() => {
2629
- if (dataUpdatedAt) {
2630
- setLastUpdated(new Date(dataUpdatedAt))
2631
- }
2632
- }, [dataUpdatedAt])
2735
+ if (!dataUpdatedAt) return
2736
+ if (resources === lastDataRef.current) return
2737
+ lastDataRef.current = resources
2738
+ setLastUpdated(new Date(dataUpdatedAt))
2739
+ }, [dataUpdatedAt, resources])
2633
2740
 
2634
2741
  // Derive counts — prefer lightweight resourceCounts prop over full query data
2635
2742
  const counts = useMemo(() => {
@@ -3468,12 +3575,7 @@ export function ResourcesView({
3468
3575
  </span>
3469
3576
  )}
3470
3577
 
3471
- {lastUpdated && (
3472
- <div className="flex items-center gap-1.5 text-xs text-theme-text-tertiary">
3473
- <Clock className="w-3.5 h-3.5" />
3474
- <span>Updated <span className="inline-block min-w-[4ch] tabular-nums">{formatAge(lastUpdated.toISOString())}</span></span>
3475
- </div>
3476
- )}
3578
+ {lastUpdated && <LastUpdatedLabel lastUpdated={lastUpdated} />}
3477
3579
  {/* Column picker */}
3478
3580
  <div className="relative" ref={columnPickerRef}>
3479
3581
  <button
@@ -3970,7 +4072,14 @@ function CellContent({ resource, kind, column, group, majorityNodeMinorVersion,
3970
4072
  )
3971
4073
  }
3972
4074
  if (column === 'age') {
3973
- return <span className="text-sm text-theme-text-secondary">{formatAge(meta.creationTimestamp)}</span>
4075
+ if (!meta.creationTimestamp) {
4076
+ return <span className="text-sm text-theme-text-secondary">-</span>
4077
+ }
4078
+ return (
4079
+ <Tooltip content={new Date(meta.creationTimestamp).toLocaleString()}>
4080
+ <span className="text-sm text-theme-text-secondary">{formatAge(meta.creationTimestamp)}</span>
4081
+ </Tooltip>
4082
+ )
3974
4083
  }
3975
4084
 
3976
4085
  // Kind-specific columns (normalize CRD singular names like 'ScaledObject' → 'scaledobjects')
@@ -5716,6 +5825,3 @@ function EventCell({ resource, column }: { resource: any; column: string }) {
5716
5825
  return <span className="text-sm text-theme-text-tertiary">-</span>
5717
5826
  }
5718
5827
  }
5719
-
5720
-
5721
-
@@ -14,6 +14,6 @@ export * from './resource-utils-trivy'
14
14
  export * from './resource-utils-traefik'
15
15
  export * from './resource-utils-velero'
16
16
  export { ResourcesView, ResourcesViewDataContext } from './ResourcesView'
17
- export type { ResourceQueryResult } from './ResourcesView'
17
+ export type { ResourceQueryResult, ExtraColumn } from './ResourcesView'
18
18
  export { ResourcesSidebar } from './ResourcesSidebar'
19
19
  export type { ResourcesSidebarProps, SelectedKindInfo, PinnedItem } from './ResourcesSidebar'
@@ -55,7 +55,7 @@ export function KnativeConfigurationRenderer({ data, onNavigate }: KnativeConfig
55
55
  <div className="text-xs text-theme-text-secondary truncate" title={c.image}>{c.image}</div>
56
56
  {c.ports && c.ports.length > 0 && (
57
57
  <div className="text-xs text-theme-text-tertiary mt-1">
58
- Ports: {c.ports.map((p: any) => `${p.containerPort}/${p.protocol || 'TCP'}`).join(', ')}
58
+ Ports: {c.ports.map((p: any) => `${p.name ? `${p.name}: ` : ''}${p.containerPort}/${p.protocol || 'TCP'}`).join(', ')}
59
59
  </div>
60
60
  )}
61
61
  </div>
@@ -67,7 +67,7 @@ export function KnativeRevisionRenderer({ data }: KnativeRevisionRendererProps)
67
67
  <div className="text-xs text-theme-text-secondary truncate" title={c.image}>{c.image}</div>
68
68
  {c.ports && c.ports.length > 0 && (
69
69
  <div className="text-xs text-theme-text-tertiary mt-1">
70
- Ports: {c.ports.map((p: any) => `${p.containerPort}/${p.protocol || 'TCP'}`).join(', ')}
70
+ Ports: {c.ports.map((p: any) => `${p.name ? `${p.name}: ` : ''}${p.containerPort}/${p.protocol || 'TCP'}`).join(', ')}
71
71
  </div>
72
72
  )}
73
73
  {c.resources && (c.resources.requests || c.resources.limits) && (
@@ -123,7 +123,7 @@ export function KnativeServiceRenderer({ data, onNavigate }: KnativeServiceRende
123
123
  <div className="text-xs text-theme-text-secondary truncate" title={c.image}>{c.image}</div>
124
124
  {c.ports && c.ports.length > 0 && (
125
125
  <div className="text-xs text-theme-text-tertiary mt-1">
126
- Ports: {c.ports.map((p: any) => `${p.containerPort}/${p.protocol || 'TCP'}`).join(', ')}
126
+ Ports: {c.ports.map((p: any) => `${p.name ? `${p.name}: ` : ''}${p.containerPort}/${p.protocol || 'TCP'}`).join(', ')}
127
127
  </div>
128
128
  )}
129
129
  </div>
@@ -635,7 +635,8 @@ export function PodRenderer({
635
635
  <span>Ports:</span>
636
636
  {container.ports.map((p: any) => (
637
637
  canPortForward && renderPortAction ? (
638
- <span key={`${p.containerPort}-${p.protocol || 'TCP'}`}>
638
+ <span key={`${p.name || ''}-${p.containerPort}-${p.protocol || 'TCP'}`} className="inline-flex items-center gap-1">
639
+ {p.name && <span className="text-theme-text-tertiary">{p.name}:</span>}
639
640
  {renderPortAction({
640
641
  namespace,
641
642
  podName,
@@ -645,8 +646,8 @@ export function PodRenderer({
645
646
  })}
646
647
  </span>
647
648
  ) : (
648
- <span key={`${p.containerPort}-${p.protocol || 'TCP'}`} className="text-theme-text-tertiary">
649
- {p.containerPort}/{p.protocol || 'TCP'}
649
+ <span key={`${p.name || ''}-${p.containerPort}-${p.protocol || 'TCP'}`} className="text-theme-text-tertiary">
650
+ {p.name ? `${p.name}: ` : ''}{p.containerPort}/{p.protocol || 'TCP'}
650
651
  </span>
651
652
  )
652
653
  ))}
@@ -6,6 +6,7 @@ import { Section, PropertyList, Property, AlertBanner } from '../../ui/drawer-co
6
6
  import { ConfirmDialog } from '../../ui/ConfirmDialog'
7
7
  import type { SecretCertificateInfo, CertificateInfo } from '../../../types'
8
8
  import { pluralize } from '../../../utils/pluralize'
9
+ import { cleanResourceForYaml } from '../../../utils/yaml'
9
10
 
10
11
  interface SecretRendererProps {
11
12
  data: any
@@ -74,16 +75,9 @@ export function SecretRenderer({ data, certificateInfo, resourceData, onSaveSecr
74
75
 
75
76
  const handleSave = useCallback(async (key: string, newValue: string) => {
76
77
  if (!onSaveSecretValue || !resourceData) return
77
- const cleaned = structuredClone(resourceData)
78
- delete cleaned.status
79
- if (cleaned.metadata) {
80
- delete cleaned.metadata.managedFields
81
- delete cleaned.metadata.resourceVersion
82
- delete cleaned.metadata.uid
83
- delete cleaned.metadata.creationTimestamp
84
- delete cleaned.metadata.generation
85
- }
86
- // Encode with UTF-8 support
78
+ const cleaned = cleanResourceForYaml(resourceData)
79
+ if (!cleaned.data) cleaned.data = {}
80
+ // btoa is byte-only; round-trip through encodeURIComponent/unescape so non-ASCII secret values survive base64 encoding.
87
81
  cleaned.data[key] = btoa(unescape(encodeURIComponent(newValue)))
88
82
  const yaml = yamlStringify(cleaned, { lineWidth: 0, indent: 2 })
89
83
  try {
@@ -9,11 +9,14 @@ import {
9
9
  XCircle,
10
10
  AlertTriangle,
11
11
  } from 'lucide-react'
12
+ import { Download } from 'lucide-react'
12
13
  import { stringify as yamlStringify } from 'yaml'
13
14
  import { CodeViewer } from '../ui/CodeViewer'
14
15
  import { YamlEditor } from '../ui/YamlEditor'
15
16
  import { Tooltip } from '../ui/Tooltip'
16
17
  import type { SelectedResource } from '../../types'
18
+ import { resourceToYaml } from '../../utils/yaml'
19
+ import { triggerDownload } from '../../utils/download'
17
20
 
18
21
  // ============================================================================
19
22
  // SUCCESS ANIMATION
@@ -118,9 +121,14 @@ interface EditableYamlViewProps {
118
121
  saveError?: string | null
119
122
  /** Duplicate handler — opens create dialog with this resource's YAML */
120
123
  onDuplicate?: (params: { kind: string; namespace: string; name: string; yaml: string }) => void
124
+ /**
125
+ * Optional override for the download trigger — desktop builds inject a native save dialog here.
126
+ * Falls back to a browser blob download when omitted.
127
+ */
128
+ onDownload?: (content: string, mime: string, filename: string) => void
121
129
  }
122
130
 
123
- export function EditableYamlView({ resource, data, onCopy, copied, onSaved, onSave, isSaving, saveError, onDuplicate }: EditableYamlViewProps) {
131
+ export function EditableYamlView({ resource, data, onCopy, copied, onSaved, onSave, isSaving, saveError, onDuplicate, onDownload }: EditableYamlViewProps) {
124
132
  const draftKey = `radar_yaml_draft:${resource.kind}/${resource.namespace}/${resource.name}`
125
133
 
126
134
  // Restore draft from sessionStorage (e.g., after session-expiry redirect).
@@ -149,26 +157,20 @@ export function EditableYamlView({ resource, data, onCopy, copied, onSaved, onSa
149
157
  }
150
158
  }, [isEditing, editedYaml, draftKey])
151
159
 
152
- // Convert resource to YAML for editing
153
- const convertToYaml = useCallback((d: any) => {
154
- if (!d) return ''
155
- const cleaned = { ...d }
156
- delete cleaned.status
157
- if (cleaned.metadata) {
158
- delete cleaned.metadata.managedFields
159
- delete cleaned.metadata.resourceVersion
160
- delete cleaned.metadata.uid
161
- delete cleaned.metadata.creationTimestamp
162
- delete cleaned.metadata.generation
163
- }
164
- return yamlStringify(cleaned, { lineWidth: 0, indent: 2 })
165
- }, [])
160
+ const handleDownload = useCallback(() => {
161
+ const yaml = resourceToYaml(data)
162
+ if (!yaml) return
163
+ // Prefer the canonical singular Kind from the manifest (e.g. "Pod") over the URL plural ("pods").
164
+ const kindForFile = (data?.kind || resource.kind || 'resource').toLowerCase()
165
+ const slug = `${kindForFile}-${resource.name}`.replace(/[^a-z0-9._-]+/g, '-')
166
+ triggerDownload(yaml, 'application/yaml', `${slug}.yaml`, onDownload)
167
+ }, [data, resource.kind, resource.name, onDownload])
166
168
 
167
169
  const handleStartEdit = useCallback(() => {
168
- setEditedYaml(convertToYaml(data))
170
+ setEditedYaml(resourceToYaml(data))
169
171
  setYamlErrors([])
170
172
  setIsEditing(true)
171
- }, [data, convertToYaml])
173
+ }, [data])
172
174
 
173
175
  const handleCancelEdit = useCallback(() => {
174
176
  setIsEditing(false)
@@ -328,6 +330,15 @@ export function EditableYamlView({ resource, data, onCopy, copied, onSaved, onSa
328
330
  {copied ? <Check className="w-3.5 h-3.5 text-green-400" /> : <Copy className="w-3.5 h-3.5" />}
329
331
  Copy
330
332
  </button>
333
+ <Tooltip content="Download manifest as YAML (server-generated fields stripped)">
334
+ <button
335
+ onClick={handleDownload}
336
+ className="flex items-center gap-1 px-2 py-1 text-xs text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded"
337
+ >
338
+ <Download className="w-3.5 h-3.5" />
339
+ Download
340
+ </button>
341
+ </Tooltip>
331
342
  {onDuplicate && (
332
343
  <Tooltip content="Duplicate as new resource">
333
344
  <button
@@ -0,0 +1,45 @@
1
+ import { GitBranch } from 'lucide-react'
2
+ import { clsx } from 'clsx'
3
+ import type { GitOpsOwnerRef } from '../../utils/gitops-owner'
4
+
5
+ // ManagedByChip renders the "Managed by <ArgoCD/FluxCD app>" affordance for
6
+ // resources detected (via labels/annotations) to be GitOps-managed. The chip
7
+ // is clickable when the host wires `onOpen`; integrators that don't surface
8
+ // a GitOps tab can omit the handler and the chip degrades to a passive badge
9
+ // so the relationship is still visible.
10
+ //
11
+ // Variant:
12
+ // - inline (default): compact pill suitable for header rows and resource list rows
13
+ // - block: starts a new line with mt-1 spacing, used in WorkloadView title strip
14
+ export function ManagedByChip({
15
+ owner,
16
+ onOpen,
17
+ variant = 'inline',
18
+ }: {
19
+ owner: GitOpsOwnerRef
20
+ onOpen?: (ref: GitOpsOwnerRef) => void
21
+ variant?: 'inline' | 'block'
22
+ }) {
23
+ const toolLabel = owner.tool === 'argocd' ? 'ArgoCD' : 'FluxCD'
24
+ const label = owner.namespace ? `${owner.namespace}/${owner.name}` : owner.name
25
+ const title = `Managed by ${toolLabel} · ${label}`
26
+ const interactive = !!onOpen
27
+ const Wrapper = interactive ? 'button' : 'span'
28
+ return (
29
+ <Wrapper
30
+ {...(interactive
31
+ ? { type: 'button' as const, onClick: () => onOpen?.(owner) }
32
+ : {})}
33
+ title={title}
34
+ className={clsx(
35
+ 'inline-flex items-center gap-1 rounded border border-theme-border bg-theme-elevated px-1.5 py-0.5 text-[11px] text-theme-text-secondary',
36
+ variant === 'block' && 'mt-1',
37
+ interactive && 'hover:border-skyhook-500/60 hover:text-skyhook-500 transition-colors',
38
+ )}
39
+ >
40
+ <GitBranch className="h-3 w-3 shrink-0" />
41
+ <span className="shrink-0 text-theme-text-tertiary">Managed by</span>
42
+ <span className="truncate max-w-[180px]">{label}</span>
43
+ </Wrapper>
44
+ )
45
+ }
@@ -2,3 +2,4 @@ export { ResourceRendererDispatch, getResourceStatus, type RendererOverrides } f
2
2
  export { EditableYamlView, SaveSuccessAnimation } from './EditableYamlView'
3
3
  export { ResourceActionsBar, RevisionHistoryDialog } from './ResourceActionsBar'
4
4
  export { CreateResourceDialog, type CreateResourceDialogProps, type ApplyResult } from './CreateResourceDialog'
5
+ export { ManagedByChip } from './ManagedByChip'
@@ -22,7 +22,7 @@ import { isChangeEvent, isK8sEvent, isHistoricalEvent, isOperation } from '../..
22
22
  import { getOperationColor, getHealthBadgeColor, SEVERITY_BADGE } from '../../utils/badge-colors'
23
23
  import { ResourceRefBadge } from '../ui/drawer-components'
24
24
  import type { NavigateToResource } from '../../utils/navigation'
25
- import { kindToPlural, refToSelectedResource } from '../../utils/navigation'
25
+ import { kindToPlural, refToSelectedResource, apiVersionToGroup } from '../../utils/navigation'
26
26
  import { pluralize } from '../../utils/pluralize'
27
27
  import { useRegisterShortcut } from '../../hooks/useKeyboardShortcuts'
28
28
 
@@ -589,7 +589,7 @@ function ActivityCard({ item, expanded, onToggle, onResourceClick }: ActivityCar
589
589
  <button
590
590
  onClick={(e) => {
591
591
  e.stopPropagation()
592
- onResourceClick?.({ kind: kindToPlural(item.kind), namespace: item.namespace, name: item.name })
592
+ onResourceClick?.({ kind: kindToPlural(item.kind), namespace: item.namespace, name: item.name, group: apiVersionToGroup(item.apiVersion) })
593
593
  }}
594
594
  className="flex items-center gap-2 hover:bg-theme-elevated/50 rounded px-1 -ml-1 transition-colors group"
595
595
  >
@@ -732,7 +732,7 @@ function AggregatedActivityCard({ first, last, count, reason, expanded, onToggle
732
732
  <button
733
733
  onClick={(e) => {
734
734
  e.stopPropagation()
735
- onResourceClick?.({ kind: kindToPlural(first.kind), namespace: first.namespace, name: first.name })
735
+ onResourceClick?.({ kind: kindToPlural(first.kind), namespace: first.namespace, name: first.name, group: apiVersionToGroup(first.apiVersion) })
736
736
  }}
737
737
  className="flex items-center gap-2 hover:bg-theme-elevated/50 rounded px-1 -ml-1 transition-colors group"
738
738
  >
@@ -445,10 +445,11 @@ export function TopologyGraph({
445
445
  // Structure key for change detection — includes groupLevels so chip↔cardGrid triggers relayout
446
446
  const structureKey = useMemo(() => {
447
447
  const nodeIds = workingNodes.map(n => n.id).sort().join(',')
448
+ const edgeIds = workingEdges.map(e => `${e.source}->${e.target}:${e.type}`).sort().join(',')
448
449
  const levels = Array.from(groupLevels.entries()).sort().map(([k, v]) => `${k}:${v}`).join(',')
449
450
  const expanded = Array.from(expandedPodGroups).sort().join(',')
450
- return `${viewMode}|${nodeIds}|${levels}|${expanded}|${groupingMode}|${layoutRetryCount}`
451
- }, [viewMode, workingNodes, groupLevels, expandedPodGroups, groupingMode, layoutRetryCount])
451
+ return `${viewMode}|${nodeIds}|${edgeIds}|${levels}|${expanded}|${groupingMode}|${layoutRetryCount}`
452
+ }, [viewMode, workingNodes, workingEdges, groupLevels, expandedPodGroups, groupingMode, layoutRetryCount])
452
453
 
453
454
  // Layout when structure changes - use hierarchical ELK layout
454
455
  useEffect(() => {
@@ -218,7 +218,16 @@ export function Tooltip({
218
218
  ref={tooltipRef}
219
219
  className={clsx(
220
220
  'fixed z-[9999] px-2 py-1 text-xs text-theme-text-primary bg-theme-base rounded shadow-lg border border-theme-border',
221
- 'whitespace-nowrap pointer-events-none',
221
+ // Cap width + allow wrapping. Long tooltips (multi-sentence
222
+ // disabled-reason explanations) used to render with
223
+ // whitespace-nowrap, producing 700+ px wide single-line
224
+ // tooltips that the viewport collision logic then pushed
225
+ // away from their trigger to fit on screen — visually
226
+ // detached from the element they were describing. With
227
+ // max-w-xs (320px) + whitespace-normal, short tooltips
228
+ // still fit on one line (content shorter than max-width)
229
+ // and long ones wrap naturally near the trigger.
230
+ 'max-w-xs whitespace-normal break-words pointer-events-none',
222
231
  className
223
232
  )}
224
233
  style={{
@@ -519,7 +519,7 @@ export function PodTemplateSection({ template }: { template: any }) {
519
519
  <div className="text-xs text-theme-text-secondary truncate" title={c.image}>{c.image}</div>
520
520
  {c.ports && (
521
521
  <div className="text-xs text-theme-text-tertiary mt-1">
522
- Ports: {c.ports.map((p: any) => `${p.containerPort}/${p.protocol || 'TCP'}`).join(', ')}
522
+ Ports: {c.ports.map((p: any) => `${p.name ? `${p.name}: ` : ''}${p.containerPort}/${p.protocol || 'TCP'}`).join(', ')}
523
523
  </div>
524
524
  )}
525
525
  </div>
@@ -724,7 +724,8 @@ export function RelatedResourcesSection({ relationships, onNavigate }: RelatedRe
724
724
  (relationships.configRefs && relationships.configRefs.length > 0) ||
725
725
  (relationships.consumers && relationships.consumers.length > 0) ||
726
726
  (relationships.scalers && relationships.scalers.length > 0) ||
727
- (relationships.policies && relationships.policies.length > 0) ||
727
+ (relationships.pdbs && relationships.pdbs.length > 0) ||
728
+ (relationships.networkPolicies && relationships.networkPolicies.length > 0) ||
728
729
  relationships.scaleTarget
729
730
 
730
731
  if (!hasRelationships) return null
@@ -765,25 +766,12 @@ export function RelatedResourcesSection({ relationships, onNavigate }: RelatedRe
765
766
  {relationships.scalers && relationships.scalers.length > 0 && (
766
767
  <RelationshipGroup label="Autoscaler" refs={dedupeRefs(relationships.scalers)} onNavigate={onNavigate} />
767
768
  )}
768
- {relationships.policies && relationships.policies.length > 0 && (() => {
769
- const policyKinds = new Set(['NetworkPolicy', 'CiliumNetworkPolicy', 'CiliumClusterwideNetworkPolicy', 'ClusterNetworkPolicy'])
770
- const pdbs = relationships.policies.filter(r => r.kind === 'PodDisruptionBudget')
771
- const netpols = relationships.policies.filter(r => policyKinds.has(r.kind))
772
- const other = relationships.policies.filter(r => r.kind !== 'PodDisruptionBudget' && !policyKinds.has(r.kind))
773
- return (
774
- <>
775
- {pdbs.length > 0 && (
776
- <RelationshipGroup label="Disruption Budget" refs={dedupeRefs(pdbs)} onNavigate={onNavigate} />
777
- )}
778
- {netpols.length > 0 && (
779
- <RelationshipGroup label="Network Policies" refs={dedupeRefs(netpols)} onNavigate={onNavigate} />
780
- )}
781
- {other.length > 0 && (
782
- <RelationshipGroup label="Policies" refs={dedupeRefs(other)} onNavigate={onNavigate} />
783
- )}
784
- </>
785
- )
786
- })()}
769
+ {relationships.pdbs && relationships.pdbs.length > 0 && (
770
+ <RelationshipGroup label="Disruption Budget" refs={dedupeRefs(relationships.pdbs)} onNavigate={onNavigate} />
771
+ )}
772
+ {relationships.networkPolicies && relationships.networkPolicies.length > 0 && (
773
+ <RelationshipGroup label="Network Policies" refs={dedupeRefs(relationships.networkPolicies)} onNavigate={onNavigate} />
774
+ )}
787
775
  {relationships.scaleTarget && (
788
776
  <RelationshipGroup label="Scale Target" refs={[relationships.scaleTarget]} onNavigate={onNavigate} />
789
777
  )}