@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.
- package/package.json +3 -3
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +2 -3
- package/src/components/dock/BottomDock.tsx +24 -17
- package/src/components/dock/DockContext.tsx +39 -0
- package/src/components/gitops/index.ts +4 -0
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +1456 -0
- package/src/components/gitops/insights/index.ts +6 -0
- package/src/components/gitops/insights/insights-helpers.test.ts +98 -0
- package/src/components/gitops/insights/insights-helpers.ts +99 -0
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +799 -0
- package/src/components/gitops/tree/index.ts +4 -0
- package/src/components/gitops/tree/tree-helpers.ts +42 -0
- package/src/components/resources/ResourcesView.tsx +71 -18
- package/src/components/resources/index.ts +1 -1
- package/src/components/resources/renderers/KnativeConfigurationRenderer.tsx +1 -1
- package/src/components/resources/renderers/KnativeRevisionRenderer.tsx +1 -1
- package/src/components/resources/renderers/KnativeServiceRenderer.tsx +1 -1
- package/src/components/resources/renderers/PodRenderer.tsx +4 -3
- package/src/components/resources/renderers/SecretRenderer.tsx +4 -10
- package/src/components/shared/EditableYamlView.tsx +28 -17
- package/src/components/shared/ManagedByChip.tsx +45 -0
- package/src/components/shared/index.ts +1 -0
- package/src/components/timeline/TimelineList.tsx +3 -3
- package/src/components/topology/TopologyGraph.tsx +3 -2
- package/src/components/ui/Tooltip.tsx +10 -1
- package/src/components/ui/drawer-components.tsx +1 -1
- package/src/components/workload/ResourceDetailDrawer.tsx +5 -3
- package/src/components/workload/WorkloadView.tsx +66 -0
- package/src/hooks/useKeyboardShortcuts.tsx +3 -2
- package/src/index.ts +3 -0
- package/src/types/core.ts +19 -2
- package/src/types/gitops-insights.ts +193 -0
- package/src/types/gitops-tree.ts +57 -0
- package/src/types/index.ts +2 -0
- package/src/utils/badge-colors.ts +6 -1
- package/src/utils/format.ts +28 -0
- package/src/utils/gitops-owner.test.ts +136 -0
- package/src/utils/gitops-owner.ts +92 -0
- package/src/utils/gitops-route.test.ts +78 -0
- package/src/utils/gitops-route.ts +104 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/navigation.ts +14 -0
- package/src/utils/resource-hierarchy.ts +47 -3
- package/src/utils/yaml.test.ts +101 -0
- package/src/utils/yaml.ts +26 -0
|
@@ -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
|
|
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
|
|
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
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
}, [locationPathname, locationSearch])
|
|
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
|
|
2466
|
-
|
|
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 =
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
if (!
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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(
|
|
170
|
+
setEditedYaml(resourceToYaml(data))
|
|
169
171
|
setYamlErrors([])
|
|
170
172
|
setIsEditing(true)
|
|
171
|
-
}, [data
|
|
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
|
-
|
|
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
|
-
'
|
|
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(
|
|
128
|
+
width: expanded ? `calc(100% - ${leftOffset}px)` : drawerWidth,
|
|
127
129
|
top: headerHeight,
|
|
128
|
-
height: `calc(
|
|
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' }),
|