@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.
- package/package.json +4 -4
- 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/GitOpsDetailLayout.tsx +621 -0
- package/src/components/gitops/GitOpsGraphFilterRail.tsx +216 -0
- package/src/components/gitops/GitOpsTableView.tsx +1441 -0
- package/src/components/gitops/RollbackDialog.tsx +117 -0
- package/src/components/gitops/SyncOptionsDialog.tsx +160 -0
- package/src/components/gitops/detail-helpers.test.ts +97 -0
- package/src/components/gitops/detail-helpers.ts +112 -0
- package/src/components/gitops/index.ts +55 -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/short-cluster-name.test.ts +36 -0
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +799 -0
- package/src/components/gitops/tree/index.ts +6 -0
- package/src/components/gitops/tree/merge.test.ts +240 -0
- package/src/components/gitops/tree/merge.ts +160 -0
- package/src/components/gitops/tree/tree-helpers.ts +42 -0
- package/src/components/resources/ResourcesSidebar.tsx +42 -15
- package/src/components/resources/ResourcesView.tsx +136 -30
- 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 +9 -21
- 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 +48 -6
- 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 +31 -1
- package/src/utils/format.ts +28 -0
- package/src/utils/gitops-owner.test.ts +95 -0
- package/src/utils/gitops-owner.ts +55 -0
- package/src/utils/gitops-route.test.ts +78 -0
- package/src/utils/gitops-route.ts +104 -0
- package/src/utils/helm-status.test.ts +50 -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
|
@@ -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
|
|
@@ -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
|
|
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
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
}, [locationPathname, locationSearch])
|
|
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
|
|
2466
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
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
|
-
|
|
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 =
|
|
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>
|
|
@@ -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.
|
|
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.
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
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
|
)}
|