@skyhook-io/radar-app 1.16.0 → 1.16.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/radar-app",
3
- "version": "1.16.0",
3
+ "version": "1.16.1",
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",
@@ -42,7 +42,7 @@
42
42
  "yaml": "^2.9.0"
43
43
  },
44
44
  "peerDependencies": {
45
- "@skyhook-io/k8s-ui": ">=1.16.0",
45
+ "@skyhook-io/k8s-ui": ">=1.16.1",
46
46
  "@tanstack/react-query": ">=5",
47
47
  "@xyflow/react": ">=12.0.0",
48
48
  "clsx": ">=2",
package/src/api/client.ts CHANGED
@@ -1782,6 +1782,7 @@ export function useRadarFeature(feature: RadarFeature) {
1782
1782
  const support = radarFeatureSupport(feature, capabilities, currentVersion)
1783
1783
 
1784
1784
  return {
1785
+ support,
1785
1786
  gatedKey: support === 'unsupported' ? ['radar-feature-unsupported'] : [],
1786
1787
  guard: <T,>(request: () => Promise<T>): Promise<T> =>
1787
1788
  guardRadarFeature(feature, support, { currentVersion, latestVersion }, request),
@@ -2946,14 +2947,28 @@ function workloadHistoryPath(kind: string, namespace: string, name: string, grou
2946
2947
  // The newest page of one workload's history: the workload, what it owns, K8s
2947
2948
  // Events about those, and the resources attached to it. Keyed under
2948
2949
  // "changes" so the live-update invalidation refreshes it.
2950
+ // A Radar that predates the scoped history endpoint settles as unsupported;
2951
+ // WorkloadView then falls back to the namespace's changes.
2949
2952
  export function useWorkloadHistory(kind: string, namespace: string, name: string, group?: string, enabled = true) {
2950
- return useQuery<WorkloadHistoryPage>({
2951
- queryKey: ["changes", "workload-history", kind, namespace, name, group ?? ""],
2952
- queryFn: ({ signal }) => fetchJSON(workloadHistoryPath(kind, namespace, name, group), signal),
2953
+ const { guard, gatedKey, support } = useRadarFeature("workloadHistory");
2954
+ const query = useQuery<WorkloadHistoryPage>({
2955
+ queryKey: ["changes", "workload-history", kind, namespace, name, group ?? "", ...gatedKey],
2956
+ queryFn: ({ signal }) =>
2957
+ guard(() => fetchJSON<WorkloadHistoryPage>(workloadHistoryPath(kind, namespace, name, group), signal)),
2953
2958
  staleTime: 5000,
2954
- refetchInterval: CHANGES_REFRESH_INTERVAL_MS,
2959
+ retry: shouldRetryRadarQuery,
2960
+ refetchInterval: (query) =>
2961
+ query.state.error instanceof RadarFeatureUnsupportedError ? false : CHANGES_REFRESH_INTERVAL_MS,
2955
2962
  enabled,
2956
2963
  });
2964
+ // A probe answered by an older Radar settles as unsupported and stops
2965
+ // polling; once /capabilities confirms the feature (the agent was upgraded),
2966
+ // ask again rather than staying on the fallback.
2967
+ const { error, refetch } = query;
2968
+ useEffect(() => {
2969
+ if (support === "supported" && error instanceof RadarFeatureUnsupportedError) void refetch();
2970
+ }, [support, error, refetch]);
2971
+ return query;
2957
2972
  }
2958
2973
 
2959
2974
  export function fetchWorkloadHistoryPage(kind: string, namespace: string, name: string, group: string | undefined, beforeSeq: number): Promise<WorkloadHistoryPage> {
@@ -4,7 +4,7 @@ import { createRoot } from 'react-dom/client'
4
4
  import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
5
5
  import { afterEach, beforeEach, expect, it, vi } from 'vitest'
6
6
  import { getRadarUpgradeRequirement } from '@skyhook-io/k8s-ui'
7
- import { ApiError, isNotFoundError, useApplications, useCapacityPoolDetail, useDrainPlan, usePodEnvironment } from './client'
7
+ import { ApiError, isNotFoundError, useApplications, useCapacityPoolDetail, useDrainPlan, usePodEnvironment, useWorkloadHistory } from './client'
8
8
  import { getApiBase } from './config'
9
9
  import { usePolicyResource } from './policy'
10
10
  import { isRadarFeatureUnsupported } from './radarFeatures'
@@ -188,3 +188,36 @@ it('never asks a Radar older than v1.8 for applications', async () => {
188
188
  expect(element.textContent).toBe('upgrade')
189
189
  expect(asked('/applications')).toBe(false)
190
190
  })
191
+
192
+ function History() {
193
+ const { data, error } = useWorkloadHistory('deployments', 'shop', 'web', 'apps')
194
+ return <span>{data ? 'data' : isRadarFeatureUnsupported(error, 'workloadHistory') ? 'fallback' : 'pending'}</span>
195
+ }
196
+
197
+ it('never asks a Radar whose capabilities omit workload history', async () => {
198
+ client.setQueryData(['capabilities'], { features: { policyResource: true } })
199
+ await render(<History />)
200
+ expect(element.textContent).toBe('fallback')
201
+ expect(asked('/history')).toBe(false)
202
+ })
203
+
204
+ it("reads the router's 404 on workload history as unsupported before capabilities answer", async () => {
205
+ host.radarVersion = undefined
206
+ chiRoutes = ['/workloads/deployments/shop/web/history']
207
+ await render(<History />)
208
+ expect(element.textContent).toBe('fallback')
209
+ expect(requested.filter((url) => url.includes('/history'))).toHaveLength(1)
210
+ })
211
+
212
+ it('asks again once capabilities confirm workload history after a probe hit an older Radar', async () => {
213
+ host.radarVersion = undefined
214
+ chiRoutes = ['/workloads/deployments/shop/web/history']
215
+ await render(<History />)
216
+ expect(element.textContent).toBe('fallback')
217
+
218
+ chiRoutes = []
219
+ await act(async () => { client.setQueryData(['capabilities'], { features: { workloadHistory: true } }) })
220
+ await settle()
221
+ await settle()
222
+ expect(requested.filter((url) => url.includes('/history'))).toHaveLength(2)
223
+ })
@@ -56,6 +56,15 @@ describe('radarFeatureSupport', () => {
56
56
  })
57
57
  })
58
58
 
59
+ describe('workload history, whose flag shipped with its endpoint', () => {
60
+ it('trusts a loaded capabilities answer, and probes before it arrives', () => {
61
+ expect(radarFeatureSupport('workloadHistory', { features: { workloadHistory: true } }, undefined)).toBe('supported')
62
+ expect(radarFeatureSupport('workloadHistory', { features: { policyResource: true } }, 'v1.15.0')).toBe('unsupported')
63
+ expect(radarFeatureSupport('workloadHistory', { features: {} }, 'dev')).toBe('unsupported')
64
+ expect(radarFeatureSupport('workloadHistory', undefined, 'v1.15.0')).toBe('unknown')
65
+ })
66
+ })
67
+
59
68
  describe('radarSpecSupport for a feature whose flag shipped with its endpoint', () => {
60
69
  const driftAlerts: RadarFeatureSpec = { label: 'Drift alerts', flag: 'resourceIssues', flagShippedWithEndpoint: true }
61
70
 
@@ -34,6 +34,7 @@ export const RADAR_FEATURES = {
34
34
  upgradeReadiness: { label: 'Upgrade impact reports', minimumVersion: 'v1.9.0' },
35
35
  drainPlan: { label: 'Drain plans', minimumVersion: 'v1.14.0' },
36
36
  applications: { label: 'Applications', minimumVersion: 'v1.7.7' },
37
+ workloadHistory: { label: 'Workload history', flag: 'workloadHistory', flagShippedWithEndpoint: true },
37
38
  } as const satisfies Record<string, RadarFeatureSpec>
38
39
 
39
40
  export type RadarFeature = keyof typeof RADAR_FEATURES
@@ -49,6 +49,7 @@ import {
49
49
  import {
50
50
  useWorkloadHistory,
51
51
  fetchWorkloadHistoryPage,
52
+ useChanges,
52
53
  useResourceWithRelationships,
53
54
  usePodLogs,
54
55
  useTopology,
@@ -803,18 +804,32 @@ export function WorkloadView({
803
804
  // colliding kind) under the wrong group and refetch once it settles.
804
805
  const historyGroupSettled = Boolean(rest.group) || resource !== undefined || resourceError != null
805
806
  const historyQuery = useWorkloadHistory(apiKind, namespace, name, effectiveGroup, expanded && historyGroupSettled)
807
+ // A Radar without the scoped history endpoint gets the namespace's newest
808
+ // changes instead, which the timeline filters to this workload.
809
+ const historyFallback = isRadarFeatureUnsupported(historyQuery.error, 'workloadHistory')
810
+ const namespaceChanges = useChanges({
811
+ namespaces: [namespace],
812
+ timeRange: 'all',
813
+ includeK8sEvents: true,
814
+ includeManaged: true,
815
+ limit: 10000,
816
+ enabled: expanded && historyFallback,
817
+ })
806
818
  const {
807
- events: allEvents,
819
+ events: scopedEvents,
808
820
  truncated: historyTruncated,
809
821
  loadOlder: loadOlderHistory,
810
822
  loadingOlder: loadingOlderHistory,
811
823
  olderError: olderHistoryError,
812
824
  } = useHistoryPaging(
813
825
  `${effectiveGroup ?? ''}/${apiKind}/${namespace}/${name}`,
814
- historyQuery.data,
826
+ historyFallback ? undefined : historyQuery.data,
815
827
  (beforeSeq) => fetchWorkloadHistoryPage(apiKind, namespace, name, effectiveGroup, beforeSeq),
816
828
  )
817
- const eventsLoading = historyQuery.isLoading || (expanded && !historyGroupSettled)
829
+ const allEvents = historyFallback ? namespaceChanges.data : scopedEvents
830
+ const eventsLoading = historyFallback
831
+ ? namespaceChanges.isLoading
832
+ : historyQuery.isLoading || (expanded && !historyGroupSettled)
818
833
 
819
834
  // RBAC
820
835
  const canUpdateSecrets = useCanUpdateSecrets()
@@ -1211,14 +1226,14 @@ export function WorkloadView({
1211
1226
  refetch={refetchResourceAndRuns}
1212
1227
  // Timeline
1213
1228
  allEvents={allEvents}
1214
- historyTruncated={historyTruncated}
1215
- onLoadOlderHistory={loadOlderHistory}
1216
- loadingOlderHistory={loadingOlderHistory}
1217
- olderHistoryError={olderHistoryError}
1218
- historyScoped
1219
- historyIncomplete={Boolean(historyQuery.data?.incomplete)}
1220
- historyError={historyQuery.error as Error | null}
1221
- onRetryHistory={() => void historyQuery.refetch()}
1229
+ historyTruncated={!historyFallback && historyTruncated}
1230
+ onLoadOlderHistory={historyFallback ? undefined : loadOlderHistory}
1231
+ loadingOlderHistory={!historyFallback && loadingOlderHistory}
1232
+ olderHistoryError={historyFallback ? null : olderHistoryError}
1233
+ historyScoped={!historyFallback}
1234
+ historyIncomplete={!historyFallback && Boolean(historyQuery.data?.incomplete)}
1235
+ historyError={(historyFallback ? namespaceChanges.error : historyQuery.error) as Error | null}
1236
+ onRetryHistory={() => void (historyFallback ? namespaceChanges.refetch() : historyQuery.refetch())}
1222
1237
  relatedTimelineEvents={relatedTimelineEvents}
1223
1238
  eventsLoading={eventsLoading || (batchExecution && batchKind !== 'JobSet' && batchRunsQuery.isLoading)}
1224
1239
  topology={topology}