@skyhook-io/k8s-ui 1.5.13 → 1.6.0

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 (45) hide show
  1. package/package.json +3 -3
  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/index.ts +4 -0
  6. package/src/components/gitops/insights/GitOpsInsightViews.tsx +1456 -0
  7. package/src/components/gitops/insights/index.ts +6 -0
  8. package/src/components/gitops/insights/insights-helpers.test.ts +98 -0
  9. package/src/components/gitops/insights/insights-helpers.ts +99 -0
  10. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +799 -0
  11. package/src/components/gitops/tree/index.ts +4 -0
  12. package/src/components/gitops/tree/tree-helpers.ts +42 -0
  13. package/src/components/resources/ResourcesView.tsx +71 -18
  14. package/src/components/resources/index.ts +1 -1
  15. package/src/components/resources/renderers/KnativeConfigurationRenderer.tsx +1 -1
  16. package/src/components/resources/renderers/KnativeRevisionRenderer.tsx +1 -1
  17. package/src/components/resources/renderers/KnativeServiceRenderer.tsx +1 -1
  18. package/src/components/resources/renderers/PodRenderer.tsx +4 -3
  19. package/src/components/resources/renderers/SecretRenderer.tsx +4 -10
  20. package/src/components/shared/EditableYamlView.tsx +28 -17
  21. package/src/components/shared/ManagedByChip.tsx +45 -0
  22. package/src/components/shared/index.ts +1 -0
  23. package/src/components/timeline/TimelineList.tsx +3 -3
  24. package/src/components/topology/TopologyGraph.tsx +3 -2
  25. package/src/components/ui/Tooltip.tsx +10 -1
  26. package/src/components/ui/drawer-components.tsx +1 -1
  27. package/src/components/workload/ResourceDetailDrawer.tsx +5 -3
  28. package/src/components/workload/WorkloadView.tsx +66 -0
  29. package/src/hooks/useKeyboardShortcuts.tsx +3 -2
  30. package/src/index.ts +3 -0
  31. package/src/types/core.ts +19 -2
  32. package/src/types/gitops-insights.ts +193 -0
  33. package/src/types/gitops-tree.ts +57 -0
  34. package/src/types/index.ts +2 -0
  35. package/src/utils/badge-colors.ts +6 -1
  36. package/src/utils/format.ts +28 -0
  37. package/src/utils/gitops-owner.test.ts +136 -0
  38. package/src/utils/gitops-owner.ts +92 -0
  39. package/src/utils/gitops-route.test.ts +78 -0
  40. package/src/utils/gitops-route.ts +104 -0
  41. package/src/utils/index.ts +2 -0
  42. package/src/utils/navigation.ts +14 -0
  43. package/src/utils/resource-hierarchy.ts +47 -3
  44. package/src/utils/yaml.test.ts +101 -0
  45. package/src/utils/yaml.ts +26 -0
@@ -0,0 +1,4 @@
1
+ export { GitOpsTreeGraph } from './GitOpsTreeGraph'
2
+ export type { GitOpsTreePreset } from './GitOpsTreeGraph'
3
+ export type { GitOpsTreeFilters } from './tree-helpers'
4
+ export { gitOpsFilterSet, hasGitOpsTreeFilters, matchesGitOpsTreeFilters } from './tree-helpers'
@@ -0,0 +1,42 @@
1
+ import type { GitOpsTreeNode } from '../../../types'
2
+
3
+ export interface GitOpsTreeFilters {
4
+ kinds?: Set<string> | string[]
5
+ namespaces?: Set<string> | string[]
6
+ sync?: Set<string> | string[]
7
+ health?: Set<string> | string[]
8
+ roles?: Set<string> | string[]
9
+ }
10
+
11
+ export function gitOpsFilterSet(values?: Set<string> | string[]): Set<string> | undefined {
12
+ if (!values) return undefined
13
+ const set = values instanceof Set ? values : new Set(values)
14
+ return set.size > 0 ? set : undefined
15
+ }
16
+
17
+ export function matchesGitOpsTreeFilters(node: GitOpsTreeNode, filters?: GitOpsTreeFilters): boolean {
18
+ if (!filters) return true
19
+ const kinds = gitOpsFilterSet(filters.kinds)
20
+ const namespaces = gitOpsFilterSet(filters.namespaces)
21
+ const sync = gitOpsFilterSet(filters.sync)
22
+ const health = gitOpsFilterSet(filters.health)
23
+ const roles = gitOpsFilterSet(filters.roles)
24
+
25
+ if (kinds && !kinds.has(node.ref.kind)) return false
26
+ if (namespaces && !namespaces.has(node.ref.namespace || '(cluster)')) return false
27
+ if (sync && !sync.has(node.sync || 'Unknown')) return false
28
+ if (health && !health.has(node.health || 'Unknown')) return false
29
+ if (roles && !roles.has(node.role)) return false
30
+ return true
31
+ }
32
+
33
+ export function hasGitOpsTreeFilters(filters?: GitOpsTreeFilters): boolean {
34
+ if (!filters) return false
35
+ return Boolean(
36
+ gitOpsFilterSet(filters.kinds) ||
37
+ gitOpsFilterSet(filters.namespaces) ||
38
+ gitOpsFilterSet(filters.sync) ||
39
+ gitOpsFilterSet(filters.health) ||
40
+ gitOpsFilterSet(filters.roles),
41
+ )
42
+ }
@@ -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
@@ -1772,18 +1775,23 @@ export function ResourcesView({
1772
1775
  onSelectedKindChange,
1773
1776
  hideSidebar = false,
1774
1777
  onCreateResource,
1778
+ defaultKind = DEFAULT_KIND_INFO,
1775
1779
  extraLeadingColumns,
1776
1780
  onRowSelect,
1777
1781
  }: ResourcesViewProps) {
1778
1782
  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)
1783
+ const [selectedKind, setSelectedKind] = useState<SelectedKindInfo>(() => getInitialKindFromURL(basePath, defaultKind, locationPathname, locationSearch))
1784
+ // Sync selectedKind from URL when the URL changes (browser back, external sidebar navigation).
1785
+ // Deps are URL-derived only — including selectedKind.name/group would race against pending
1786
+ // navigation: a sidebar click flips state before navigate() lands, this effect re-reads the
1787
+ // stale URL, and reverts the kind. The window into a stale URL between state change and URL
1788
+ // update is what produced the "blink and fail to navigate" bug.
1781
1789
  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
1790
+ const kindFromURL = getInitialKindFromURL(basePath, defaultKind, locationPathname, locationSearch)
1791
+ setSelectedKind((prev) =>
1792
+ kindFromURL.name !== prev.name || kindFromURL.group !== prev.group ? kindFromURL : prev,
1793
+ )
1794
+ }, [basePath, defaultKind, locationPathname, locationSearch])
1787
1795
  // Notify parent of selected kind changes (including initial mount)
1788
1796
  useEffect(() => {
1789
1797
  onSelectedKindChange?.(selectedKind)
@@ -1850,6 +1858,9 @@ export function ResourcesView({
1850
1858
  const hasProcessedInitialResource = useRef(false)
1851
1859
  // Set by sidebar kind change to push a browser history entry (vs replace for filter changes)
1852
1860
  const shouldPushHistory = useRef(false)
1861
+ // Used by the URL-write effect to distinguish drawer-to-drawer navigation (A -> B, push)
1862
+ // from initial open (null -> X) and close (X -> null), which stay as URL replaces.
1863
+ const prevSelectedResourceRef = useRef<SelectedResource | null>(null)
1853
1864
 
1854
1865
  // Ref to search input for keyboard shortcut
1855
1866
  const searchInputRef = useRef<HTMLInputElement>(null)
@@ -2336,7 +2347,7 @@ export function ResourcesView({
2336
2347
  isSyncingFromURL.current = true
2337
2348
 
2338
2349
  // Re-read URL params and update state
2339
- const newKind = getInitialKindFromURL(basePath, locationPathname, locationSearch)
2350
+ const newKind = getInitialKindFromURL(basePath, defaultKind, locationPathname, locationSearch)
2340
2351
  const newFilters = getInitialFiltersFromURL()
2341
2352
 
2342
2353
  // Update kind if it changed
@@ -2366,7 +2377,7 @@ export function ResourcesView({
2366
2377
  requestAnimationFrame(() => {
2367
2378
  isSyncingFromURL.current = false
2368
2379
  })
2369
- }, [locationPathname, locationSearch]) // Re-run when injected URL path or search params change
2380
+ }, [locationPathname, locationSearch, defaultKind, basePath]) // Re-run when injected URL path or search params change
2370
2381
 
2371
2382
  const navigate = useMemo(() => {
2372
2383
  if (!onNavigate) return (_pathOrObj: any, _opts?: any) => {}
@@ -2434,6 +2445,25 @@ export function ResourcesView({
2434
2445
  const newPath = `${basePath}/${kindInfo.name}`
2435
2446
  const queryStr = params.toString()
2436
2447
 
2448
+ // No-op guard: if the target URL already matches the address bar, skip the
2449
+ // navigate. Without this, a state catch-up after browser POP (App-level
2450
+ // POP→state sync re-running this effect) would push a duplicate entry on
2451
+ // top of the popped state — making the next Back appear to do nothing or
2452
+ // (with multi-namespace name collisions) jump to a sibling resource via
2453
+ // auto-resolution. Reading window.location avoids needing host-injected
2454
+ // navigationType.
2455
+ if (typeof window !== 'undefined') {
2456
+ const currentPathname = window.location.pathname
2457
+ const currentSearch = window.location.search.replace(/^\?/, '')
2458
+ // Compare using basename-relative target path against window.pathname,
2459
+ // which may include a host basename (e.g. /c/{cluster}). Treat a path
2460
+ // suffix match as equal so embedded hosts don't false-trigger a write.
2461
+ const pathMatches = currentPathname === newPath || currentPathname.endsWith(newPath)
2462
+ if (pathMatches && currentSearch === queryStr) {
2463
+ return
2464
+ }
2465
+ }
2466
+
2437
2467
  // Route both push and replace through `navigate` (which honors the
2438
2468
  // onNavigate prop). The previous direct `window.history.replaceState`
2439
2469
  // bypass meant a host that wants to suppress URL writes (passing
@@ -2446,12 +2476,12 @@ export function ResourcesView({
2446
2476
  useEffect(() => {
2447
2477
  // Skip URL update if we're syncing FROM the URL (e.g., browser back button)
2448
2478
  if (isSyncingFromURL.current) {
2449
-
2479
+ prevSelectedResourceRef.current = selectedResource ?? null
2450
2480
  return
2451
2481
  }
2452
2482
  // Skip on initial mount so we don't strip ?resource= before the mount effect reads it
2453
2483
  if (!hasProcessedInitialResource.current) {
2454
-
2484
+ prevSelectedResourceRef.current = selectedResource ?? null
2455
2485
  return
2456
2486
  }
2457
2487
  // Skip URL update if selectedResource's kind doesn't match selectedKind (still syncing)
@@ -2462,12 +2492,38 @@ export function ResourcesView({
2462
2492
  return // Wait for kind sync effect to run first
2463
2493
  }
2464
2494
  }
2465
- // Push history when kind changes (so browser back/forward works), replace for filter changes
2466
- const pushHistory = shouldPushHistory.current
2495
+ // Push history for navigations (so browser back works); replace for filter / drawer-toggle changes.
2496
+ // A navigation is one of: explicit sidebar/keyboard kind switch (shouldPushHistory),
2497
+ // kind change driven by external setSelectedResource (pathname differs from target — e.g. clicking a
2498
+ // Parent Gateway from a TCPRoute drawer), or a drawer-to-drawer switch within the same kind
2499
+ // (selectedResource A -> B, both non-null and different). Initial open (null -> X) and close (X -> null)
2500
+ // stay as replace because they don't represent a destination the user wants to "go back" to.
2501
+ const targetPath = `${basePath}/${selectedKind.name}`
2502
+ // Compare basename-relative paths. Hosts that mount the app under a non-empty basename
2503
+ // (e.g. Radar Hub at /c/{cluster}) inject `locationPathname` from useLocation(), which strips
2504
+ // the basename — `window.location.pathname` still includes it, so reading window directly
2505
+ // would never match `targetPath` (basename-relative) and force every URL write to push.
2506
+ const currentPath =
2507
+ locationPathname !== undefined
2508
+ ? locationPathname
2509
+ : typeof window !== 'undefined'
2510
+ ? window.location.pathname
2511
+ : ''
2512
+ const pathChanged = currentPath !== targetPath
2513
+ const prev = prevSelectedResourceRef.current
2514
+ const current = selectedResource ?? null
2515
+ const drawerSwitched =
2516
+ prev !== null && current !== null &&
2517
+ (prev.namespace !== current.namespace ||
2518
+ prev.name !== current.name ||
2519
+ prev.kind !== current.kind ||
2520
+ (prev.group ?? '') !== (current.group ?? ''))
2521
+ const pushHistory = shouldPushHistory.current || pathChanged || drawerSwitched
2467
2522
  shouldPushHistory.current = false
2523
+ prevSelectedResourceRef.current = current
2468
2524
 
2469
2525
  updateURL(selectedKind, searchTerm, columnFilters, problemFilters, showInactiveReplicaSets, selectedResource?.namespace, selectedResource?.name, pushHistory)
2470
- }, [selectedKind, searchTerm, columnFilters, problemFilters, showInactiveReplicaSets, selectedResource, updateURL])
2526
+ }, [selectedKind, searchTerm, columnFilters, problemFilters, showInactiveReplicaSets, selectedResource, updateURL, basePath, locationPathname])
2471
2527
 
2472
2528
  // Handle resource click from URL on mount
2473
2529
  useEffect(() => {
@@ -5716,6 +5772,3 @@ function EventCell({ resource, column }: { resource: any; column: string }) {
5716
5772
  return <span className="text-sm text-theme-text-tertiary">-</span>
5717
5773
  }
5718
5774
  }
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>
@@ -2,6 +2,7 @@ import { useState, useCallback, useEffect, useRef, type ReactNode } from 'react'
2
2
  import { TRANSITION_DRAWER } from '../../utils/animation'
3
3
  import { clsx } from 'clsx'
4
4
  import type { SelectedResource } from '../../types'
5
+ import { useDockReservedHeight } from '../dock/DockContext'
5
6
 
6
7
  interface ResourceDetailDrawerProps {
7
8
  resource: SelectedResource
@@ -111,11 +112,12 @@ export function ResourceDetailDrawer({ resource, onClose, onNavigate, initialTab
111
112
  }, [expanded, onNavigateToResource, onNavigate])
112
113
 
113
114
  const headerHeight = headerHeightProp ?? 49
115
+ const dockInset = useDockReservedHeight()
114
116
 
115
117
  return (
116
118
  <div
117
119
  className={clsx(
118
- 'fixed right-0 bg-theme-surface border-l border-theme-border flex flex-col shadow-drawer z-40',
120
+ 'absolute right-0 bg-theme-surface border-l border-theme-border flex flex-col shadow-drawer z-40',
119
121
  TRANSITION_DRAWER,
120
122
  isOpen
121
123
  ? 'translate-x-0 opacity-100'
@@ -123,9 +125,9 @@ export function ResourceDetailDrawer({ resource, onClose, onNavigate, initialTab
123
125
  expanded && '!border-l-0',
124
126
  )}
125
127
  style={{
126
- width: expanded ? `calc(100vw - ${leftOffset}px)` : drawerWidth,
128
+ width: expanded ? `calc(100% - ${leftOffset}px)` : drawerWidth,
127
129
  top: headerHeight,
128
- height: `calc(100vh - ${headerHeight}px)`,
130
+ height: `calc(100% - ${headerHeight}px - ${dockInset}px)`,
129
131
  // Collapse is instant — no animation, content and width snap together.
130
132
  // Expand + slide-in/out animate via TRANSITION_DRAWER class.
131
133
  ...(isCollapsing && { transition: 'none' }),