@skyhook-io/radar-app 1.8.0 → 1.8.2
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 +2 -2
- package/src/App.tsx +95 -16
- package/src/api/client.ts +64 -3
- package/src/components/DebugOverlay.tsx +1 -1
- package/src/components/applications/ApplicationsView.tsx +1 -1
- package/src/components/compare/CompareViewRoute.tsx +13 -5
- package/src/components/cost/CostTrendChart.tsx +3 -3
- package/src/components/cost/CostView.tsx +1 -1
- package/src/components/helm/ChartBrowser.tsx +1 -1
- package/src/components/helm/HelmReleaseDrawer.tsx +233 -19
- package/src/components/helm/HelmView.tsx +85 -16
- package/src/components/helm/InstallWizard.tsx +1 -1
- package/src/components/helm/RevisionHistory.tsx +46 -2
- package/src/components/helm/TrackChartSourceDialog.tsx +141 -0
- package/src/components/home/ActivitySummary.tsx +4 -1
- package/src/components/home/TrafficSummary.tsx +2 -2
- package/src/components/home/mcpToolCatalog.ts +5 -5
- package/src/components/issues/IssuesPane.tsx +2 -2
- package/src/components/nav/PrimaryNavRail.tsx +1 -1
- package/src/components/portforward/PortForwardManager.tsx +70 -8
- package/src/components/resource/PrometheusChartsGrid.tsx +1 -1
- package/src/components/resources/ResourcesView.tsx +2 -0
- package/src/components/settings/MyPermissionsDialog.tsx +64 -4
- package/src/components/shared/LargeClusterNamespacePicker.tsx +1 -1
- package/src/components/traffic/TrafficGraph.tsx +29 -20
- package/src/components/traffic/TrafficView.tsx +3 -3
- package/src/components/ui/DiagnosticsOverlay.tsx +1 -1
- package/src/components/ui/ShortcutHelpOverlay.tsx +1 -1
- package/src/components/ui/command-items.ts +1 -1
- package/src/components/workload/WorkloadView.tsx +17 -17
- package/src/context/ConnectionContext.tsx +29 -2
- package/src/main.tsx +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skyhook-io/radar-app",
|
|
3
|
-
"version": "1.8.
|
|
3
|
+
"version": "1.8.2",
|
|
4
4
|
"description": "Radar's full web UI as a reusable React component. Used by Radar's own binary and by external consumers like Radar Cloud.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
"@tailwindcss/typography": "^0.5.20",
|
|
58
58
|
"@tailwindcss/vite": "^4.3.1",
|
|
59
59
|
"@tanstack/react-query": "^5.100.14",
|
|
60
|
-
"@types/node": "^
|
|
60
|
+
"@types/node": "^26.0.0",
|
|
61
61
|
"@types/react": "^19.2.17",
|
|
62
62
|
"@types/react-dom": "^19.2.3",
|
|
63
63
|
"@vitejs/plugin-react": "^6.0.2",
|
package/src/App.tsx
CHANGED
|
@@ -138,14 +138,19 @@ function AuthBarrier({ authMode }: { authMode: string }) {
|
|
|
138
138
|
src={radarLoadingIcon}
|
|
139
139
|
alt=""
|
|
140
140
|
aria-hidden
|
|
141
|
-
|
|
141
|
+
// Integer offset (vw/2 − 22) — matches the Connecting/Opening splashes;
|
|
142
|
+
// avoids sub-pixel jitter from translate(-50%) on odd-width viewports.
|
|
143
|
+
className="absolute w-11 h-11"
|
|
144
|
+
style={{ left: 'calc(50% - 22px)', top: 'calc(50% - 22px)' }}
|
|
142
145
|
/>
|
|
143
|
-
<
|
|
144
|
-
className="absolute left-1/2 -translate-x-1/2
|
|
146
|
+
<div
|
|
147
|
+
className="absolute left-1/2 -translate-x-1/2 text-center"
|
|
145
148
|
style={{ top: 'calc(50% + 34px)' }}
|
|
146
149
|
>
|
|
147
|
-
|
|
148
|
-
|
|
150
|
+
<p className="whitespace-nowrap text-[17px] font-semibold tracking-tight text-theme-text-primary">
|
|
151
|
+
Redirecting to login…
|
|
152
|
+
</p>
|
|
153
|
+
</div>
|
|
149
154
|
</div>
|
|
150
155
|
</div>
|
|
151
156
|
)
|
|
@@ -171,6 +176,15 @@ function AuthBarrier({ authMode }: { authMode: string }) {
|
|
|
171
176
|
)
|
|
172
177
|
}
|
|
173
178
|
|
|
179
|
+
// Identity of the "page" a non-URL-backed peek drawer belongs to. Pathname alone
|
|
180
|
+
// is not enough: Applications keeps the list and an app's detail on the same
|
|
181
|
+
// `/applications` pathname and distinguishes them with `?app=`, so a Back from
|
|
182
|
+
// detail to list would otherwise leave the peek orphaned. Only `app` is included
|
|
183
|
+
// (not the whole query) so filter/tab/namespace churn doesn't close the peek.
|
|
184
|
+
function peekOwnerKey(pathname: string, search: string): string {
|
|
185
|
+
return `${pathname}${new URLSearchParams(search).get('app') ?? ''}`
|
|
186
|
+
}
|
|
187
|
+
|
|
174
188
|
function AppInner() {
|
|
175
189
|
const navigate = useNavigate()
|
|
176
190
|
const location = useLocation()
|
|
@@ -410,6 +424,9 @@ function AppInner() {
|
|
|
410
424
|
// selected drawer resource. This covers both in-view kind switches and
|
|
411
425
|
// cross-kind navigations from expanded drawers (for example Node -> View Pods).
|
|
412
426
|
const prevResourcesKindKeyRef = useRef<string | null>(null)
|
|
427
|
+
// Owner-key (pathname + ?app) a non-URL-backed peek was opened on; see
|
|
428
|
+
// navigateToResource and peekOwnerKey.
|
|
429
|
+
const peekOwnerKeyRef = useRef<string | null>(null)
|
|
413
430
|
const currentResourceKindSlug = normalizedResourcesKindSlug.toLowerCase()
|
|
414
431
|
const currentResourceGroup = searchParams.get('apiGroup') ?? ''
|
|
415
432
|
const selectedResourceKindSlug = selectedResource ? kindToPlural(selectedResource.kind).toLowerCase() : ''
|
|
@@ -421,9 +438,28 @@ function AppInner() {
|
|
|
421
438
|
const resourcesKindRouteChanged = mainView === 'resources' &&
|
|
422
439
|
prevResourcesKindKeyRef.current !== null &&
|
|
423
440
|
prevResourcesKindKeyRef.current !== `${currentResourceGroup}/${currentResourceKindSlug}`
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
441
|
+
|
|
442
|
+
// A peek opened outside /resources (topology, GitOps, Applications) carries no
|
|
443
|
+
// URL backing, so the only signal that the page beneath it has navigated is
|
|
444
|
+
// that its owner-key (pathname + ?app) no longer matches where it was opened.
|
|
445
|
+
// Hiding it here, at render time, closes the orphan on Back without adding
|
|
446
|
+
// another clearing effect that would race the `suppressViewClearRef` lifecycle.
|
|
447
|
+
// The /resources case is URL-backed and handled above; an expanded drawer
|
|
448
|
+
// (drawerExpanded) legitimately lives at /workload and must stay open there.
|
|
449
|
+
const peekRouteOrphaned = !!selectedResource && !drawerExpanded && mainView !== 'resources' &&
|
|
450
|
+
peekOwnerKeyRef.current !== null &&
|
|
451
|
+
peekOwnerKeyRef.current !== peekOwnerKey(location.pathname, location.search)
|
|
452
|
+
|
|
453
|
+
// In Applications the inline WorkloadView (?workload) and the peek drawer are
|
|
454
|
+
// mutually exclusive — never two detail surfaces at once. ?workload is the
|
|
455
|
+
// single source of truth: while it's set the peek yields to the inline view.
|
|
456
|
+
// (Opening a child peek from Applications clears ?workload, see onOpenResource.)
|
|
457
|
+
const appsInlineWorkloadActive = mainView === 'applications' && searchParams.has('workload')
|
|
458
|
+
|
|
459
|
+
const routeSelectedResource =
|
|
460
|
+
(resourcesKindRouteChanged && selectedResourceRouteMismatch) || peekRouteOrphaned || appsInlineWorkloadActive
|
|
461
|
+
? null
|
|
462
|
+
: selectedResource
|
|
427
463
|
|
|
428
464
|
useEffect(() => {
|
|
429
465
|
if (mainView !== 'resources') {
|
|
@@ -458,6 +494,13 @@ function AppInner() {
|
|
|
458
494
|
|
|
459
495
|
// Navigate to a resource — uses View Transitions cross-fade when drawer is already open
|
|
460
496
|
const navigateToResource = useCallback((res: SelectedResource, tab: 'detail' | 'yaml' = 'detail') => {
|
|
497
|
+
// Record the page this peek was opened on. Outside /resources the drawer is
|
|
498
|
+
// not URL-backed, so this ref is what lets the render-time gate below close
|
|
499
|
+
// the peek when the page under it changes (e.g. browser Back off a GitOps
|
|
500
|
+
// detail page, or Applications detail → list via ?app). window.location is
|
|
501
|
+
// read (not the `location` closure) so the value is always current
|
|
502
|
+
// regardless of this callback's memoization.
|
|
503
|
+
peekOwnerKeyRef.current = peekOwnerKey(window.location.pathname, window.location.search)
|
|
461
504
|
const update = () => { setDrawerInitialTab(tab); setSelectedResource(res) }
|
|
462
505
|
// Skip the cross-fade animation entirely on first open (no
|
|
463
506
|
// `selectedResource`); otherwise route through
|
|
@@ -920,7 +963,7 @@ function AppInner() {
|
|
|
920
963
|
name: node.name,
|
|
921
964
|
group: apiVersionToGroup(node.data.apiVersion as string | undefined),
|
|
922
965
|
})
|
|
923
|
-
}, [navigate])
|
|
966
|
+
}, [navigate, navigateToResource])
|
|
924
967
|
|
|
925
968
|
// Serialize namespaces for stable dependency tracking
|
|
926
969
|
const namespacesKey = namespaces.join(',')
|
|
@@ -1136,6 +1179,11 @@ function AppInner() {
|
|
|
1136
1179
|
// Switching to specific namespaces - disable namespace grouping
|
|
1137
1180
|
setGroupingMode('none')
|
|
1138
1181
|
}
|
|
1182
|
+
// Intentionally runs ONLY when the namespace selection changes. It reads the
|
|
1183
|
+
// current groupingMode but must not re-run when grouping changes, or it would
|
|
1184
|
+
// immediately revert a manual/fleet grouping choice. namespacesKey is the
|
|
1185
|
+
// manual dependency standing in for the namespaces array.
|
|
1186
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1139
1187
|
}, [namespacesKey])
|
|
1140
1188
|
|
|
1141
1189
|
// Clear resource selection when changing views or namespaces
|
|
@@ -1362,7 +1410,7 @@ function AppInner() {
|
|
|
1362
1410
|
// the bar is full, and the view's primary home is Cloud's fleet
|
|
1363
1411
|
// rail. The view still exists and is reachable via /applications
|
|
1364
1412
|
// and the view-switching shortcuts. Same treatment as Cost below.
|
|
1365
|
-
{ view: 'traffic' as const, icon: Activity, label: 'Traffic' },
|
|
1413
|
+
{ view: 'traffic' as const, icon: Activity, label: 'Live Traffic' },
|
|
1366
1414
|
// Cost is intentionally hidden from the pill bar for now — the view still
|
|
1367
1415
|
// exists and is reachable via /cost, the Home dashboard card, and the
|
|
1368
1416
|
// command palette (⌘K). Remove this comment to restore it.
|
|
@@ -1745,7 +1793,7 @@ function AppInner() {
|
|
|
1745
1793
|
<TopologySearch
|
|
1746
1794
|
nodes={filteredTopology?.nodes ?? []}
|
|
1747
1795
|
allNodes={topology?.nodes}
|
|
1748
|
-
viewModeLabel={topologyMode === 'fleet' ? 'Fleet' : topologyMode === 'traffic' ? '
|
|
1796
|
+
viewModeLabel={topologyMode === 'fleet' ? 'Fleet' : topologyMode === 'traffic' ? 'Network Flow' : 'Resources'}
|
|
1749
1797
|
onNodeSelect={handleNodeClick}
|
|
1750
1798
|
onZoomToNode={(id) => setTopologyFocus((prev) => ({ id, nonce: (prev?.nonce ?? 0) + 1 }))}
|
|
1751
1799
|
// Stack below the namespace breadcrumb (shown only for a single
|
|
@@ -1767,6 +1815,7 @@ function AppInner() {
|
|
|
1767
1815
|
showPolicyEffect={showPolicyEffect}
|
|
1768
1816
|
onShowPolicyEffectChange={setShowPolicyEffect}
|
|
1769
1817
|
showFleetMode={displayedTopology?.nodes?.some(n => FLEET_MODE_KINDS.has(n.kind as NodeKind)) ?? false}
|
|
1818
|
+
onNavigateToTraffic={() => setMainView('traffic')}
|
|
1770
1819
|
/>
|
|
1771
1820
|
</div>
|
|
1772
1821
|
</>
|
|
@@ -1829,7 +1878,10 @@ function AppInner() {
|
|
|
1829
1878
|
<GitOpsView
|
|
1830
1879
|
namespaces={namespaces}
|
|
1831
1880
|
onOpenResource={(resource) => {
|
|
1832
|
-
|
|
1881
|
+
// Route through navigateToResource so the peek records the page it
|
|
1882
|
+
// opened on — that's what lets Back off the GitOps detail page close
|
|
1883
|
+
// the drawer instead of orphaning it on the list.
|
|
1884
|
+
navigateToResource(resource)
|
|
1833
1885
|
}}
|
|
1834
1886
|
onClearNamespaces={clearAllNamespaces}
|
|
1835
1887
|
/>
|
|
@@ -1840,7 +1892,17 @@ function AppInner() {
|
|
|
1840
1892
|
<ApplicationsView
|
|
1841
1893
|
namespaces={namespaces}
|
|
1842
1894
|
onOpenResource={(resource) => {
|
|
1843
|
-
|
|
1895
|
+
// The peek and the inline WorkloadView are mutually exclusive: drop
|
|
1896
|
+
// the inline workload selection so the app graph (not a second
|
|
1897
|
+
// detail panel) sits behind the peek. Search-only change keeps the
|
|
1898
|
+
// pathname — and thus the peek's owner-path — intact.
|
|
1899
|
+
const params = new URLSearchParams(window.location.search)
|
|
1900
|
+
if (params.has('workload') || params.has('tab')) {
|
|
1901
|
+
params.delete('workload')
|
|
1902
|
+
params.delete('tab')
|
|
1903
|
+
navigate({ pathname: window.location.pathname, search: params.toString() }, { replace: true })
|
|
1904
|
+
}
|
|
1905
|
+
navigateToResource(resource)
|
|
1844
1906
|
}}
|
|
1845
1907
|
/>
|
|
1846
1908
|
)}
|
|
@@ -1861,9 +1923,26 @@ function AppInner() {
|
|
|
1861
1923
|
own fetches) while the cross-document nav lands. Covers checks /
|
|
1862
1924
|
issues / gitops with one block since only one view is active. */}
|
|
1863
1925
|
{viewTakeoverHref && (
|
|
1864
|
-
<div className="flex-1
|
|
1865
|
-
|
|
1866
|
-
|
|
1926
|
+
<div className="flex-1 relative bg-theme-base">
|
|
1927
|
+
{/* Viewport-anchored, 17px — identical to the "Connecting" splash so
|
|
1928
|
+
the mark doesn't move or resize across the takeover hand-off. */}
|
|
1929
|
+
<div className="fixed inset-0 pointer-events-none">
|
|
1930
|
+
<img
|
|
1931
|
+
src={radarLoadingIcon}
|
|
1932
|
+
alt=""
|
|
1933
|
+
aria-hidden
|
|
1934
|
+
className="absolute w-11 h-11"
|
|
1935
|
+
style={{ left: 'calc(50% - 22px)', top: 'calc(50% - 22px)' }}
|
|
1936
|
+
/>
|
|
1937
|
+
<div
|
|
1938
|
+
className="absolute left-1/2 -translate-x-1/2 text-center"
|
|
1939
|
+
style={{ top: 'calc(50% + 34px)' }}
|
|
1940
|
+
>
|
|
1941
|
+
<p className="whitespace-nowrap text-[17px] font-semibold tracking-tight text-theme-text-primary">
|
|
1942
|
+
Opening…
|
|
1943
|
+
</p>
|
|
1944
|
+
</div>
|
|
1945
|
+
</div>
|
|
1867
1946
|
</div>
|
|
1868
1947
|
)}
|
|
1869
1948
|
|
package/src/api/client.ts
CHANGED
|
@@ -1509,7 +1509,7 @@ export function useAutoPromConnect(): void {
|
|
|
1509
1509
|
if (attemptedRef.current === context) return
|
|
1510
1510
|
let cached: string | null = null
|
|
1511
1511
|
try { cached = window.localStorage.getItem(promAutoConnectKey(context)) } catch {
|
|
1512
|
-
|
|
1512
|
+
// keep the null fallback
|
|
1513
1513
|
}
|
|
1514
1514
|
|
|
1515
1515
|
attemptedRef.current = context
|
|
@@ -2396,6 +2396,19 @@ export function useHelmUpgradeInfo(namespace: string, name: string, enabled = tr
|
|
|
2396
2396
|
})
|
|
2397
2397
|
}
|
|
2398
2398
|
|
|
2399
|
+
// Available chart versions for a release (newest-first), for the upgrade dialog's
|
|
2400
|
+
// version picker. Empty when the source can't be resolved — the dialog then falls
|
|
2401
|
+
// back to the latest version from upgrade-info.
|
|
2402
|
+
export function useHelmReleaseVersions(namespace: string, name: string, enabled = true) {
|
|
2403
|
+
return useQuery<string[]>({
|
|
2404
|
+
queryKey: ['helm-release-versions', namespace, name],
|
|
2405
|
+
queryFn: () => fetchJSON(`/helm/releases/${namespace}/${name}/versions`),
|
|
2406
|
+
enabled: Boolean(namespace && name && enabled),
|
|
2407
|
+
staleTime: 30000,
|
|
2408
|
+
retry: false,
|
|
2409
|
+
})
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2399
2412
|
// Batch check for upgrade availability (for list view)
|
|
2400
2413
|
export function useHelmBatchUpgradeInfo(namespaces: string[] = [], enabled = true) {
|
|
2401
2414
|
const params = helmNamespaceParams(namespaces)
|
|
@@ -2668,6 +2681,54 @@ export function useUpdateRepositorySilent() {
|
|
|
2668
2681
|
})
|
|
2669
2682
|
}
|
|
2670
2683
|
|
|
2684
|
+
// Registered OCI chart sources (the OCI analog of `helm repo add`). Used to
|
|
2685
|
+
// track upgrades for the user's own OCI-published charts.
|
|
2686
|
+
export function useHelmOCISources() {
|
|
2687
|
+
return useQuery<string[]>({
|
|
2688
|
+
queryKey: ['helm-oci-sources'],
|
|
2689
|
+
queryFn: () => fetchJSON('/helm/oci-sources'),
|
|
2690
|
+
})
|
|
2691
|
+
}
|
|
2692
|
+
|
|
2693
|
+
async function mutateOCISource(method: 'POST' | 'DELETE', source: string): Promise<string[]> {
|
|
2694
|
+
const response = await apiFetch(`${getApiBase()}/helm/oci-sources`, {
|
|
2695
|
+
method,
|
|
2696
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2697
|
+
body: JSON.stringify({ source }),
|
|
2698
|
+
})
|
|
2699
|
+
if (!response.ok) {
|
|
2700
|
+
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
|
2701
|
+
throw new Error(error.error || `HTTP ${response.status}`)
|
|
2702
|
+
}
|
|
2703
|
+
return response.json()
|
|
2704
|
+
}
|
|
2705
|
+
|
|
2706
|
+
// Invalidate the upgrade-info queries so a newly-registered source is probed
|
|
2707
|
+
// immediately and "source not tracked" re-resolves.
|
|
2708
|
+
function invalidateHelmAfterSourceChange(queryClient: ReturnType<typeof useQueryClient>) {
|
|
2709
|
+
queryClient.invalidateQueries({ queryKey: ['helm-oci-sources'] })
|
|
2710
|
+
queryClient.invalidateQueries({ queryKey: ['helm-upgrade-info'] })
|
|
2711
|
+
queryClient.invalidateQueries({ queryKey: ['helm-batch-upgrade-info'] })
|
|
2712
|
+
}
|
|
2713
|
+
|
|
2714
|
+
export function useAddOCISource() {
|
|
2715
|
+
const queryClient = useQueryClient()
|
|
2716
|
+
return useMutation({
|
|
2717
|
+
mutationFn: (source: string) => mutateOCISource('POST', source),
|
|
2718
|
+
meta: { errorMessage: 'Failed to add chart source', successMessage: 'Chart source added' },
|
|
2719
|
+
onSuccess: () => invalidateHelmAfterSourceChange(queryClient),
|
|
2720
|
+
})
|
|
2721
|
+
}
|
|
2722
|
+
|
|
2723
|
+
export function useRemoveOCISource() {
|
|
2724
|
+
const queryClient = useQueryClient()
|
|
2725
|
+
return useMutation({
|
|
2726
|
+
mutationFn: (source: string) => mutateOCISource('DELETE', source),
|
|
2727
|
+
meta: { errorMessage: 'Failed to remove chart source', successMessage: 'Chart source removed' },
|
|
2728
|
+
onSuccess: () => invalidateHelmAfterSourceChange(queryClient),
|
|
2729
|
+
})
|
|
2730
|
+
}
|
|
2731
|
+
|
|
2671
2732
|
// Search charts across all repositories
|
|
2672
2733
|
export function useSearchCharts(query: string, allVersions = false, enabled = true) {
|
|
2673
2734
|
return useQuery<ChartSearchResult>({
|
|
@@ -3048,7 +3109,7 @@ export function useSwitchContext() {
|
|
|
3048
3109
|
} catch (error) {
|
|
3049
3110
|
clearTimeout(timeoutId)
|
|
3050
3111
|
if (error instanceof Error && error.name === 'AbortError') {
|
|
3051
|
-
throw new Error('Context switch timed out. The cluster may be unreachable.')
|
|
3112
|
+
throw new Error('Context switch timed out. The cluster may be unreachable.', { cause: error })
|
|
3052
3113
|
}
|
|
3053
3114
|
throw error
|
|
3054
3115
|
}
|
|
@@ -3151,7 +3212,7 @@ export function useSetActiveNamespace() {
|
|
|
3151
3212
|
error: error instanceof Error ? error.message : String(error),
|
|
3152
3213
|
})
|
|
3153
3214
|
if (error instanceof Error && error.name === 'AbortError') {
|
|
3154
|
-
throw new Error('Namespace switch timed out. The cluster may be unreachable.')
|
|
3215
|
+
throw new Error('Namespace switch timed out. The cluster may be unreachable.', { cause: error })
|
|
3155
3216
|
}
|
|
3156
3217
|
throw error
|
|
3157
3218
|
}
|
|
@@ -28,7 +28,7 @@ interface ApplicationsViewProps {
|
|
|
28
28
|
|
|
29
29
|
export function ApplicationsView({ namespaces, onOpenResource }: ApplicationsViewProps) {
|
|
30
30
|
const query = useApplications(namespaces)
|
|
31
|
-
const apps = query.data?.applications ?? []
|
|
31
|
+
const apps = useMemo(() => query.data?.applications ?? [], [query.data])
|
|
32
32
|
|
|
33
33
|
// Which app is open lives in the URL (?app=<key>) so the detail view is
|
|
34
34
|
// deep-linkable and the browser back button returns to the list. Opening or
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useCallback, useState } from 'react'
|
|
1
|
+
import { useCallback, useMemo, useState } from 'react'
|
|
2
2
|
import { useNavigate, useSearchParams } from 'react-router-dom'
|
|
3
3
|
import {
|
|
4
4
|
ResourceCompareView,
|
|
@@ -23,13 +23,21 @@ export function CompareViewRoute() {
|
|
|
23
23
|
// reserved for topology grouping mode and gets stripped by App.tsx's URL
|
|
24
24
|
// sync on every non-topology view.
|
|
25
25
|
const group = searchParams.get('apiGroup') ?? undefined
|
|
26
|
-
const
|
|
27
|
-
const
|
|
26
|
+
const aRaw = searchParams.get('a')
|
|
27
|
+
const bRaw = searchParams.get('b')
|
|
28
28
|
|
|
29
29
|
const [pickerOpen, setPickerOpen] = useState<CompareSide | null>(null)
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
// Memoized so the refs keep a stable identity across renders — otherwise the
|
|
32
|
+
// useCallbacks below (which depend on a/b) would be rebuilt every render.
|
|
33
|
+
const a: CompareResourceRef | null = useMemo(() => {
|
|
34
|
+
const p = parseRef(aRaw)
|
|
35
|
+
return p ? { kind, namespace: p.namespace, name: p.name, group } : null
|
|
36
|
+
}, [aRaw, kind, group])
|
|
37
|
+
const b: CompareResourceRef | null = useMemo(() => {
|
|
38
|
+
const p = parseRef(bRaw)
|
|
39
|
+
return p ? { kind, namespace: p.namespace, name: p.name, group } : null
|
|
40
|
+
}, [bRaw, kind, group])
|
|
33
41
|
|
|
34
42
|
const aQuery = useResource<unknown>(a?.kind ?? '', a?.namespace ?? '', a?.name ?? '', a?.group)
|
|
35
43
|
const bQuery = useResource<unknown>(b?.kind ?? '', b?.namespace ?? '', b?.name ?? '', b?.group)
|
|
@@ -34,7 +34,7 @@ export function CostTrendChart() {
|
|
|
34
34
|
<div className="rounded-lg border border-theme-border bg-theme-surface/50 p-4">
|
|
35
35
|
<div className="flex items-center justify-center h-[200px] text-theme-text-tertiary">
|
|
36
36
|
<Loader2 className="w-5 h-5 animate-spin mr-2" />
|
|
37
|
-
Loading cost trend
|
|
37
|
+
Loading cost trend…
|
|
38
38
|
</div>
|
|
39
39
|
</div>
|
|
40
40
|
)
|
|
@@ -164,7 +164,7 @@ function StackedAreaChart({ series }: { series: OpenCostTrendSeries[] }) {
|
|
|
164
164
|
})
|
|
165
165
|
|
|
166
166
|
return { timestamps, stacked, minTs, maxTs, yMax, seriesLookups, toX, toY, yTicks, xTicks, paths }
|
|
167
|
-
}, [series])
|
|
167
|
+
}, [series, plotHeight, plotWidth])
|
|
168
168
|
|
|
169
169
|
// Hover data — depends on hoverX + chartData, must be a separate hook (called unconditionally)
|
|
170
170
|
const hoverData = useMemo(() => {
|
|
@@ -193,7 +193,7 @@ function StackedAreaChart({ series }: { series: OpenCostTrendSeries[] }) {
|
|
|
193
193
|
})
|
|
194
194
|
|
|
195
195
|
return { ts: closestTs, x: toX(closestTs), total, points }
|
|
196
|
-
}, [hoverX, chartData, series])
|
|
196
|
+
}, [hoverX, chartData, series, plotWidth])
|
|
197
197
|
|
|
198
198
|
const handleMouseMove = useCallback((e: React.MouseEvent<SVGRectElement>) => {
|
|
199
199
|
const svg = svgRef.current
|
|
@@ -282,7 +282,7 @@ function WorkloadRows({ namespace }: { namespace: string }) {
|
|
|
282
282
|
return (
|
|
283
283
|
<div className="px-4 py-3 flex items-center gap-2 text-xs text-theme-text-tertiary bg-theme-elevated/30">
|
|
284
284
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
285
|
-
Loading workloads
|
|
285
|
+
Loading workloads…
|
|
286
286
|
</div>
|
|
287
287
|
)
|
|
288
288
|
}
|
|
@@ -190,7 +190,7 @@ export function ChartBrowser({ onChartSelect }: ChartBrowserProps) {
|
|
|
190
190
|
</button>
|
|
191
191
|
<div className="border-t border-theme-border my-1" />
|
|
192
192
|
{reposLoading ? (
|
|
193
|
-
<div className="px-3 py-2 text-sm text-theme-text-tertiary">Loading
|
|
193
|
+
<div className="px-3 py-2 text-sm text-theme-text-tertiary">Loading…</div>
|
|
194
194
|
) : repositories?.length === 0 ? (
|
|
195
195
|
<div className="px-3 py-2 text-sm text-theme-text-tertiary">No repositories configured</div>
|
|
196
196
|
) : (
|