@skyhook-io/radar-app 1.16.3 → 1.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/package.json +2 -2
  2. package/src/App.tsx +14 -7
  3. package/src/RadarApp.tsx +27 -17
  4. package/src/api/client.goneResource.test.ts +49 -0
  5. package/src/api/client.ts +115 -8
  6. package/src/api/diagnose.test.ts +40 -0
  7. package/src/api/diagnose.ts +12 -1
  8. package/src/api/radarFeatures.render.test.tsx +19 -1
  9. package/src/api/radarFeatures.ts +3 -0
  10. package/src/api/traffic.sources.test.ts +55 -0
  11. package/src/api/traffic.ts +173 -14
  12. package/src/components/applications/ApplicationsView.tsx +3 -0
  13. package/src/components/capacity/CapacityPoolDetail.tsx +18 -7
  14. package/src/components/capacity/CapacityView.test.tsx +31 -1
  15. package/src/components/capacity/shared.tsx +12 -21
  16. package/src/components/cnpg/CNPGProtection.tsx +2 -2
  17. package/src/components/diagnose/AgentSetupNotice.test.tsx +58 -0
  18. package/src/components/diagnose/AgentSetupNotice.tsx +142 -51
  19. package/src/components/diagnose/ApplyDialog.ack.test.tsx +47 -0
  20. package/src/components/diagnose/ApplyDialog.test.tsx +59 -6
  21. package/src/components/diagnose/ApplyDialog.tsx +26 -28
  22. package/src/components/diagnose/DiagnoseContext.test.ts +43 -0
  23. package/src/components/diagnose/DiagnoseContext.tsx +136 -54
  24. package/src/components/diagnose/DiagnoseSurface.tsx +16 -3
  25. package/src/components/diagnose/InvestigationView.tsx +39 -2
  26. package/src/components/diagnose/LocalDiagnoseAction.tsx +40 -20
  27. package/src/components/diagnose/agentCatalog.ts +2 -2
  28. package/src/components/diagnose/diagnoseEvidenceTypes.ts +2 -0
  29. package/src/components/dock/TrafficFlowListTab.tsx +27 -6
  30. package/src/components/execution/BatchExecutionView.tsx +11 -11
  31. package/src/components/helm/ChartBrowser.tsx +3 -3
  32. package/src/components/helm/RevisionHistory.tsx +3 -2
  33. package/src/components/home/ClusterHealthCard.tsx +7 -0
  34. package/src/components/home/mcpToolCatalog.ts +1 -1
  35. package/src/components/resources/renderers/KyvernoPolicyQueued.tsx +2 -1
  36. package/src/components/resources/renderers/NodeRenderer.tsx +7 -1
  37. package/src/components/resources/renderers/ServiceRenderer.test.tsx +81 -0
  38. package/src/components/resources/renderers/ServiceRenderer.tsx +19 -14
  39. package/src/components/settings/SettingsDialog.tsx +122 -19
  40. package/src/components/shared/LargeClusterNamespacePicker.tsx +11 -5
  41. package/src/components/timeline/TimelineList.tsx +3 -0
  42. package/src/components/timeline/TimelineRetentionNote.test.tsx +49 -0
  43. package/src/components/timeline/TimelineRetentionNote.tsx +20 -0
  44. package/src/components/timeline/TimelineView.tsx +7 -0
  45. package/src/components/traffic/TrafficConnectionsTable.tsx +144 -0
  46. package/src/components/traffic/TrafficFilterSidebar.tsx +22 -1
  47. package/src/components/traffic/TrafficFlowListContext.tsx +12 -4
  48. package/src/components/traffic/TrafficFocusSearch.tsx +172 -0
  49. package/src/components/traffic/TrafficGraph.tsx +109 -26
  50. package/src/components/traffic/TrafficGraphTooLarge.tsx +150 -0
  51. package/src/components/traffic/TrafficView.tsx +582 -149
  52. package/src/components/traffic/trafficFilters.test.ts +398 -1
  53. package/src/components/traffic/trafficFilters.ts +408 -0
  54. package/src/components/workload/WorkloadView.test.ts +2 -1
  55. package/src/components/workload/WorkloadView.tsx +135 -290
  56. package/src/context/TimelineCustomization.tsx +15 -0
  57. package/src/hooks/useGitOpsWriteGuard.test.tsx +457 -0
  58. package/src/hooks/useGitOpsWriteGuard.ts +193 -0
  59. package/src/hooks/useResolvedGitOpsOwner.ts +256 -0
@@ -1,12 +1,16 @@
1
- import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
2
- import type { TrafficSourcesResponse, TrafficFlowsResponse } from '../types'
1
+ import { useCallback, useMemo } from 'react'
2
+ import { useQuery, useMutation, useQueryClient, type QueryClient } from '@tanstack/react-query'
3
+ import type { TrafficSourcesResponse, TrafficFlowsResponse, TrafficRecordsResponse } from '../types'
4
+ import { fetchJSON as fetchApiJSON, useRadarFeature } from './client'
5
+ import { shouldRetryRadarQuery } from './radarFeatures'
3
6
  import { apiUrl, getAuthHeaders, getCredentialsMode } from './config'
4
7
  import { readErrorBody } from './httpErrors'
5
8
 
6
- async function fetchJSON<T>(path: string): Promise<T> {
9
+ async function fetchJSON<T>(path: string, signal?: AbortSignal): Promise<T> {
7
10
  const response = await fetch(apiUrl(path), {
8
11
  credentials: getCredentialsMode(),
9
12
  headers: getAuthHeaders(),
13
+ signal,
10
14
  })
11
15
  if (!response.ok) {
12
16
  const error = await readErrorBody(response)
@@ -26,34 +30,102 @@ export interface TrafficConnectionInfo {
26
30
  error?: string
27
31
  }
28
32
 
29
- // Get available traffic sources and recommendations
33
+ // Get available traffic sources and recommendations. A detection up to a
34
+ // couple of minutes old is reused when it found a source: a full one probes
35
+ // every source in turn and takes seconds, and the page that linked here has
36
+ // usually just made one. One that found none is redone, since it is about to
37
+ // be shown as setup advice. detectAgain always detects afresh.
38
+ export async function fetchTrafficSources(queryClient: QueryClient, fresh: boolean, signal?: AbortSignal): Promise<TrafficSourcesResponse> {
39
+ let sources = await fetchJSON<TrafficSourcesResponse>(fresh ? '/traffic/sources' : '/traffic/sources?recent=1', signal)
40
+ if (!fresh && !sources.detected.some(s => s.status === 'available')) {
41
+ sources = await fetchJSON<TrafficSourcesResponse>('/traffic/sources', signal)
42
+ }
43
+ // A detection is also the newest answer to "is traffic available".
44
+ queryClient.setQueryData(['traffic-sources', 'recent'], sources)
45
+ return sources
46
+ }
47
+
48
+ export async function detectTrafficSourcesAgain(queryClient: QueryClient): Promise<TrafficSourcesResponse> {
49
+ // A pending fetch of the same key would otherwise be returned in its place.
50
+ await queryClient.cancelQueries({ queryKey: ['traffic-sources'], exact: true })
51
+ return queryClient.fetchQuery({
52
+ queryKey: ['traffic-sources'],
53
+ queryFn: ({ signal }) => fetchTrafficSources(queryClient, true, signal),
54
+ staleTime: 0,
55
+ })
56
+ }
57
+
30
58
  export function useTrafficSources() {
31
- return useQuery<TrafficSourcesResponse>({
59
+ const queryClient = useQueryClient()
60
+ const query = useQuery<TrafficSourcesResponse>({
32
61
  queryKey: ['traffic-sources'],
33
- queryFn: () => fetchJSON('/traffic/sources'),
62
+ queryFn: ({ signal }) => fetchTrafficSources(queryClient, false, signal),
34
63
  staleTime: 30000, // 30 seconds
35
64
  retry: 1,
36
65
  })
66
+ const detectAgain = useCallback(() => detectTrafficSourcesAgain(queryClient), [queryClient])
67
+ return { ...query, detectAgain }
37
68
  }
38
69
 
39
- // Get traffic flows
40
- export interface UseTrafficFlowsOptions {
70
+ // Whether this cluster has a traffic source: true only once one is detected
71
+ // as available, so a page offering traffic never offers it on a guess. Accepts
72
+ // a detection up to a couple of minutes old (?recent=1), so browsing workloads
73
+ // does not probe every source on each page.
74
+ export function useTrafficAvailable(enabled = true): boolean {
75
+ // A workload focus needs the server to name each pod's workload, which
76
+ // shipped with flow records. An older Radar leaves Hubble pods unnamed, so
77
+ // the focus it opened would match nothing.
78
+ const resolvesWorkloads = useRadarFeature('trafficRecords').support === 'supported'
79
+ const { data } = useQuery<TrafficSourcesResponse>({
80
+ queryKey: ['traffic-sources', 'recent'],
81
+ queryFn: () => fetchJSON('/traffic/sources?recent=1'),
82
+ staleTime: 2 * 60_000,
83
+ retry: false,
84
+ enabled: enabled && resolvesWorkloads,
85
+ })
86
+ return resolvesWorkloads && !!data?.detected?.some(s => s.status === 'available')
87
+ }
88
+
89
+ // What the flows and records queries share: the namespaces in view, the
90
+ // window, and the traffic the view hides. The hidden traffic is sent so the
91
+ // source can drop it before it counts against its limits; the view still
92
+ // filters it too.
93
+ export interface TrafficScope {
41
94
  namespaces?: string[]
42
95
  since?: string // Duration like "5m", "1h"
43
- enabled?: boolean
96
+ excludeNamespaces?: readonly string[]
97
+ excludeHost?: boolean
44
98
  }
45
99
 
46
- export function useTrafficFlows(options: UseTrafficFlowsOptions = {}) {
47
- const { namespaces = [], since, enabled = true } = options
48
-
100
+ function trafficScopeParams({ namespaces = [], since, excludeNamespaces, excludeHost }: TrafficScope): URLSearchParams {
49
101
  const params = new URLSearchParams()
50
- // Traffic backend only supports single namespace, use first if provided
102
+ // With several namespaces in view the server reads them from the saved pick.
51
103
  if (namespaces.length === 1) params.set('namespace', namespaces[0])
52
104
  if (since) params.set('since', since)
105
+ if (excludeNamespaces?.length) params.set('excludeNamespaces', excludeNamespaces.join(','))
106
+ if (excludeHost) params.set('excludeHost', 'true')
107
+ return params
108
+ }
109
+
110
+ function trafficScopeKey({ namespaces = [], since, excludeNamespaces, excludeHost }: TrafficScope) {
111
+ return [namespaces, since, excludeNamespaces ?? [], !!excludeHost]
112
+ }
113
+
114
+ // Get traffic flows
115
+ export interface UseTrafficFlowsOptions extends TrafficScope {
116
+ /** One graph node per pod instead of per workload. */
117
+ byPod?: boolean
118
+ enabled?: boolean
119
+ }
120
+
121
+ export function useTrafficFlows(options: UseTrafficFlowsOptions = {}) {
122
+ const { enabled = true, byPod = false } = options
123
+ const params = trafficScopeParams(options)
124
+ if (byPod) params.set('groupBy', 'pod')
53
125
  const queryString = params.toString()
54
126
 
55
127
  return useQuery<TrafficFlowsResponse>({
56
- queryKey: ['traffic-flows', namespaces, since],
128
+ queryKey: ['traffic-flows', ...trafficScopeKey(options), byPod],
57
129
  queryFn: () => fetchJSON(`/traffic/flows${queryString ? `?${queryString}` : ''}`),
58
130
  staleTime: 5000, // 5 seconds
59
131
  enabled,
@@ -61,6 +133,93 @@ export function useTrafficFlows(options: UseTrafficFlowsOptions = {}) {
61
133
  })
62
134
  }
63
135
 
136
+ /** An endpoint as the server's aggregation keys it. */
137
+ export interface TrafficEndpointRef {
138
+ namespace?: string
139
+ name: string
140
+ kind?: string
141
+ workloadKind?: string
142
+ }
143
+
144
+ /** One edge of the server's aggregation, keyed as the server keys it. */
145
+ export interface TrafficEndpointPair {
146
+ source: TrafficEndpointRef
147
+ destination: TrafficEndpointRef
148
+ port: number
149
+ directionUnknown?: boolean
150
+ }
151
+
152
+ /** The selection rides in the query string, URL-encoded. Proxies in front of
153
+ * Radar commonly refuse request lines over 8 KiB (ingress-nginx's default),
154
+ * so a larger selection is not sent at all. */
155
+ const MAX_TRAFFIC_MATCH_CHARS = 6_000
156
+
157
+ /** A selection as the server matches it: its edges, or a node as the one
158
+ * endpoint it is, which every one of its edges has at an end. */
159
+ export type TrafficMatch = { pairs: TrafficEndpointPair[] } | { endpoints: TrafficEndpointRef[] }
160
+
161
+ // Every pair encodes to more than this many characters, so a selection with
162
+ // more pairs than the request can hold is rejected without serializing it.
163
+ const MIN_MATCH_PAIR_CHARS = 40
164
+
165
+ /** The whole query string's limit: the selection plus the scope beside it,
166
+ * under the 8 KiB request line proxies commonly allow. */
167
+ const MAX_TRAFFIC_QUERY_CHARS = 7_000
168
+
169
+ function encodedMatch(match: TrafficMatch): string | null {
170
+ if ('pairs' in match && match.pairs.length * MIN_MATCH_PAIR_CHARS > MAX_TRAFFIC_MATCH_CHARS) return null
171
+ const json = JSON.stringify(match)
172
+ return encodeURIComponent(json).length > MAX_TRAFFIC_MATCH_CHARS ? null : json
173
+ }
174
+
175
+ export interface UseTrafficRecordsOptions extends TrafficScope {
176
+ /** The selection, or null for no selection. */
177
+ match: TrafficMatch | null
178
+ /** Namespaces also excluded when the request has room for them; the list
179
+ * filters them out anyway. */
180
+ optionalExcludeNamespaces?: readonly string[]
181
+ enabled?: boolean
182
+ }
183
+
184
+ // The newest records behind a graph selection. The flows response carries only
185
+ // a capped sample of records, so a selected edge is looked up on its own —
186
+ // otherwise a quiet edge on a busy cluster would show nothing.
187
+ export function useTrafficRecords(options: UseTrafficRecordsOptions) {
188
+ const { enabled = true } = options
189
+ const { guard, gatedKey, support } = useRadarFeature('trafficRecords')
190
+ // Serialized when the selection changes (each refresh), not on every
191
+ // render: a merged edge can carry tens of thousands of pairs.
192
+ const match = useMemo(() => (options.match ? encodedMatch(options.match) : null), [options.match])
193
+ const optional = options.optionalExcludeNamespaces ?? []
194
+ let scope: TrafficScope = optional.length > 0
195
+ ? { ...options, excludeNamespaces: [...(options.excludeNamespaces ?? []), ...optional] }
196
+ : options
197
+ let params = trafficScopeParams(scope)
198
+ if (match) params.set('match', match)
199
+ if (optional.length > 0 && params.toString().length > MAX_TRAFFIC_QUERY_CHARS) {
200
+ scope = options
201
+ params = trafficScopeParams(scope)
202
+ if (match) params.set('match', match)
203
+ }
204
+ const tooLarge = options.match !== null && (match === null || params.toString().length > MAX_TRAFFIC_QUERY_CHARS)
205
+ // The view this answers, apart from the optional exclusions.
206
+ const view = JSON.stringify([...trafficScopeKey(options), match])
207
+
208
+ const query = useQuery<TrafficRecordsResponse>({
209
+ queryKey: ['traffic-flows', 'records', ...trafficScopeKey(scope), match, ...gatedKey],
210
+ queryFn: () => guard(() => fetchApiJSON<TrafficRecordsResponse>(`/traffic/flows/records?${params.toString()}`)),
211
+ staleTime: 5000,
212
+ enabled: enabled && match !== null && !tooLarge && support !== 'unsupported',
213
+ meta: { view },
214
+ // Kept while only the optional exclusions change (a namespace hidden in
215
+ // the sidebar, which the list filters out anyway). Not across another
216
+ // selection, window or scope: those records would show under this one.
217
+ placeholderData: (prev, prevQuery) => (prevQuery?.meta?.view === view ? prev : undefined),
218
+ retry: shouldRetryRadarQuery,
219
+ })
220
+ return { ...query, tooLarge, supported: support !== 'unsupported' }
221
+ }
222
+
64
223
  // Get active traffic source
65
224
  export function useActiveTrafficSource() {
66
225
  return useQuery<{ active: string }>({
@@ -55,6 +55,7 @@ import { useTimelineSource } from "../../context/TimelineSource";
55
55
  import { apiVersionToGroup, buildWorkloadPath, kindToPluralWithGroup } from "../../utils/navigation";
56
56
  import { WorkloadView } from "../workload/WorkloadView";
57
57
  import { ApplicationCostTab } from "../cost/ApplicationCostTab";
58
+ import { useTimelineRetentionNote } from "../../context/TimelineCustomization";
58
59
  import { isOpenCostWorkloadKind } from "../cost/kinds";
59
60
 
60
61
  type ApplicationRouteView = ApplicationView | "cost";
@@ -302,6 +303,7 @@ function AppDetailRoute({
302
303
  : historyRangeOptions[0].value;
303
304
  const historyTimelineEnabled =
304
305
  !selectedWorkloadKey && selectedView === "history";
306
+ const retentionNote = useTimelineRetentionNote();
305
307
  const historyTimelineQuery = timelineSource.useEvents({
306
308
  namespaces: appHistoryNamespaces,
307
309
  timeRange: historyRange,
@@ -665,6 +667,7 @@ function AppDetailRoute({
665
667
  historyCoverageRecordCount={historyTimelineQuery.coverage?.length ?? 0}
666
668
  historyRuntimeLimited={historyRuntimeLimited}
667
669
  historyRingTruncated={historyTimelineQuery.truncated ?? false}
670
+ historyRetentionNote={retentionNote}
668
671
  onHistoryRangeChange={setRetainedHistoryRange}
669
672
  onOpenTimeline={openApplicationTimeline}
670
673
  onOpenSource={openSource}
@@ -676,12 +676,23 @@ function LifecycleGrid({
676
676
  Nodes
677
677
  </div>
678
678
  {pool.nodes ? (
679
- <div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
680
- <MiniCount label="Ready" value={pool.nodes.ready} />
681
- <MiniCount label="Not ready" value={pool.nodes.notReady} />
682
- <MiniCount label="Cordoned" value={pool.nodes.cordoned} />
683
- <MiniCount label="Terminating" value={pool.nodes.terminating} />
684
- </div>
679
+ <>
680
+ <div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
681
+ <MiniCount label="Ready" value={pool.nodes.operational ? pool.nodes.operational.ready : pool.nodes.ready} />
682
+ <MiniCount label="Not ready" value={pool.nodes.operational ? pool.nodes.operational.notReady : pool.nodes.notReady} />
683
+ <MiniCount label="Cordoned" value={pool.nodes.operational ? pool.nodes.operational.cordoned : pool.nodes.cordoned} />
684
+ {pool.nodes.operational ? (
685
+ <MiniCount label="Removing" value={pool.nodes.operational.removing} />
686
+ ) : (
687
+ <MiniCount label="Terminating" value={pool.nodes.terminating} />
688
+ )}
689
+ </div>
690
+ {pool.nodes.operational ? pool.nodes.operational.removingUnhealthy > 0 && (
691
+ <p className="mt-2 text-xs text-theme-text-secondary">{pool.nodes.operational.removingUnhealthy} removing {pool.nodes.operational.removingUnhealthy === 1 ? "node has" : "nodes have"} critical pressure or prolonged removal.</p>
692
+ ) : (
693
+ <p className="mt-2 text-xs text-theme-text-secondary">Readiness counts only; removal status is unavailable from this Radar.</p>
694
+ )}
695
+ </>
685
696
  ) : (
686
697
  <p className="text-sm text-theme-text-secondary">
687
698
  {coverageMessage(pool.coverage.nodes, "Node lifecycle")}
@@ -1353,7 +1364,7 @@ function PoolMembersTab({
1353
1364
  />
1354
1365
  </td>
1355
1366
  <td className={TD}>
1356
- <NodeReadyBadge ready={node.ready} cordoned={node.cordoned} />
1367
+ <NodeReadyBadge ready={node.ready} cordoned={node.cordoned} lifecycle={node.lifecycle} />
1357
1368
  </td>
1358
1369
  <td className={`${TD} font-mono text-xs`}>
1359
1370
  {node.instanceType ?? "—"}
@@ -24,7 +24,7 @@ import type {
24
24
  } from "@skyhook-io/k8s-ui";
25
25
  import { CapacityView } from "./CapacityView";
26
26
  import { updateDemandSearchParam } from "./CapacityDemand";
27
- import { integrationBlock } from "./shared";
27
+ import { integrationBlock, NodeReadyBadge } from "./shared";
28
28
  import { ApiError } from "../../api/client";
29
29
  import { RadarFeatureUnsupportedError } from "../../api/radarFeatures";
30
30
 
@@ -2315,3 +2315,33 @@ describe("integrationBlock version skew", () => {
2315
2315
  expect(html).not.toContain("needs a newer Radar");
2316
2316
  });
2317
2317
  });
2318
+
2319
+ describe("Node lifecycle in Capacity", () => {
2320
+ it("uses backend lifecycle while preserving raw readiness", () => {
2321
+ const lifecycle = { label: "Removing (Karpenter)", level: "neutral" as const, removing: true, readinessFailed: false, delayed: false };
2322
+ for (const ready of [true, false, undefined]) {
2323
+ const html = renderToString(<NodeReadyBadge ready={ready} cordoned={false} lifecycle={lifecycle} />);
2324
+ expect(html).toContain("Removing (Karpenter)");
2325
+ expect(html).not.toContain("NotReady");
2326
+ expect(html).not.toContain("text-red-");
2327
+ }
2328
+ const failed = renderToString(<NodeReadyBadge ready={false} cordoned={false} lifecycle={{ ...lifecycle, label: "Removing (Karpenter) · NotReady", level: "unhealthy", readinessFailed: true }} />);
2329
+ expect(failed).toContain("NotReady");
2330
+ expect(failed).toContain("text-red-");
2331
+ const raw = renderToString(<NodeReadyBadge ready={false} cordoned={false} />);
2332
+ expect(raw).toContain("NotReady (condition)");
2333
+ expect(raw).toContain("text-red-");
2334
+ });
2335
+ it("renders exclusive operational counts, not overlapping raw counts", () => {
2336
+ const html = renderCapacity("/capacity/pools/default", (client) => client.setQueryData(
2337
+ ["capacity", "pool", "default"], poolDetailResponse({ ...cleanPoolDetail, nodes: {
2338
+ total: 6, ready: 4, notReady: 2, cordoned: 1, terminating: 2,
2339
+ operational: { total: 6, ready: 2, notReady: 1, cordoned: 1, removing: 2, removingUnhealthy: 1 },
2340
+ } }),
2341
+ ));
2342
+ expect(html).toMatch(/>2<\/div><div[^>]*>Ready<\/div>/);
2343
+ expect(html).toMatch(/>1<\/div><div[^>]*>Not ready<\/div>/);
2344
+ expect(html).toMatch(/>2<\/div><div[^>]*>Removing<\/div>/);
2345
+ expect(html.replace(/<!-- -->/g, "")).toContain("1 removing node has critical pressure or prolonged removal.");
2346
+ });
2347
+ });
@@ -14,6 +14,7 @@ import {
14
14
  } from "lucide-react";
15
15
  import {
16
16
  FreshnessControl,
17
+ healthToSeverity,
17
18
  formatDuration,
18
19
  getRadarUpgradeRequirement,
19
20
  RadarUpgradeAction,
@@ -38,6 +39,7 @@ import {
38
39
  type CapacityManagerRollupStatus,
39
40
  type CapacityManagerSummary,
40
41
  type CapacityMemberType,
42
+ type CapacityNodeMember,
41
43
  type CapacityPoolSummary,
42
44
  type CapacityQuantityObservation,
43
45
  type CapacityResourceIdentity,
@@ -603,32 +605,21 @@ export function PoolReadyBadge({ ready }: { ready?: boolean }) {
603
605
  export function NodeReadyBadge({
604
606
  ready,
605
607
  cordoned,
608
+ lifecycle,
606
609
  }: {
607
610
  ready?: boolean;
608
611
  cordoned: boolean;
612
+ lifecycle?: CapacityNodeMember["lifecycle"];
609
613
  }) {
610
- if (ready === false)
611
- return (
612
- <Badge severity="error" size="sm">
613
- NotReady
614
- </Badge>
615
- );
616
- if (ready === undefined)
617
- return (
618
- <Badge severity="neutral" size="sm">
619
- Unknown
620
- </Badge>
621
- );
622
- if (cordoned)
623
- return (
624
- <Badge severity="warning" size="sm">
625
- Cordoned
626
- </Badge>
627
- );
614
+ if (lifecycle) {
615
+ return <Badge severity={healthToSeverity(lifecycle.level)} size="sm">{lifecycle.label}</Badge>;
616
+ }
628
617
  return (
629
- <Badge severity="success" size="sm">
630
- Ready
631
- </Badge>
618
+ <WithTooltip tip="Kubernetes readiness only; removal status is unavailable from this Radar.">
619
+ <Badge severity={ready === false ? "error" : ready === undefined ? "neutral" : cordoned ? "warning" : "success"} size="sm">
620
+ {ready === false ? "NotReady (condition)" : ready === undefined ? "Readiness unknown" : cordoned ? "Cordoned" : "Ready (condition)"}
621
+ </Badge>
622
+ </WithTooltip>
632
623
  );
633
624
  }
634
625
 
@@ -1,8 +1,8 @@
1
1
  import { useMemo } from 'react'
2
+ import { formatRelativeAgeTime } from '@skyhook-io/k8s-ui/utils/format'
2
3
  import {
3
4
  Badge,
4
5
  FactValue,
5
- formatAge,
6
6
  formatDuration,
7
7
  getCNPGBackupStatus,
8
8
  getCNPGClusterBarmanPlugin,
@@ -50,7 +50,7 @@ function backupStart(b: any): string | undefined {
50
50
  }
51
51
 
52
52
  function ageText(ts?: string): string {
53
- return ts ? `${formatAge(ts)} ago` : '—'
53
+ return formatRelativeAgeTime(ts, '—')
54
54
  }
55
55
 
56
56
  interface StoreRow {
@@ -0,0 +1,58 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { renderToStaticMarkup } from "react-dom/server";
3
+ import { AgentSetupNotice } from "./AgentSetupNotice";
4
+ import type { DiagnoseSetup } from "./DiagnoseContext";
5
+
6
+ const render = (
7
+ setupState: DiagnoseSetup,
8
+ { checkingAgents = false, cliOverride = false } = {},
9
+ ) =>
10
+ renderToStaticMarkup(
11
+ <AgentSetupNotice
12
+ setupState={setupState}
13
+ cliOverride={cliOverride}
14
+ checkingAgents={checkingAgents}
15
+ agentsCheckFailed={false}
16
+ recheckAgents={async () => {}}
17
+ />,
18
+ );
19
+
20
+ describe("AgentSetupNotice", () => {
21
+ it("offers installs and a re-check, never a restart, when no CLI is found", () => {
22
+ const html = render("needs-install");
23
+ expect(html).toContain("Claude Code");
24
+ expect(html).toContain("OpenCode");
25
+ expect(html).toContain("Check again");
26
+ expect(html).not.toMatch(/restart/i);
27
+ // A desktop user can't set an environment variable for a Dock launch.
28
+ expect(html).not.toContain("RADAR_AI_CLI_BIN");
29
+ });
30
+
31
+ it("shows the check in progress", () => {
32
+ const html = render("needs-install", { checkingAgents: true });
33
+ expect(html).toContain("Checking…");
34
+ expect(html).toContain("disabled");
35
+ });
36
+
37
+ it("names the override only when the server says it is set", () => {
38
+ for (const state of ["needs-restart", "needs-install"] as const) {
39
+ const html = render(state, { cliOverride: true });
40
+ expect(html).toContain("RADAR_AI_CLI_BIN");
41
+ expect(html).toContain("restart Radar");
42
+ // Detection is off while the variable is set, so an install can't help.
43
+ expect(html).not.toContain("Install one of these");
44
+ // A working file put back at the pinned path is picked up without one.
45
+ expect(html).toContain("Check again");
46
+ }
47
+ });
48
+
49
+ it("says nothing it can't know when an agent is reported but off", () => {
50
+ // Radar Hub reports a hosted agent that is supported but not enabled when
51
+ // its own runner is down: no CLI, no startup output, no override.
52
+ const html = render("needs-restart");
53
+ expect(html).toContain("aren&#x27;t available right now");
54
+ expect(html).not.toMatch(/RADAR_AI_CLI_BIN|agent CLI|startup output/);
55
+ expect(html).not.toContain("Install one of these");
56
+ expect(html).toContain("Check again");
57
+ });
58
+ });