@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.
- package/package.json +2 -2
- package/src/App.tsx +14 -7
- package/src/RadarApp.tsx +27 -17
- package/src/api/client.goneResource.test.ts +49 -0
- package/src/api/client.ts +115 -8
- package/src/api/diagnose.test.ts +40 -0
- package/src/api/diagnose.ts +12 -1
- package/src/api/radarFeatures.render.test.tsx +19 -1
- package/src/api/radarFeatures.ts +3 -0
- package/src/api/traffic.sources.test.ts +55 -0
- package/src/api/traffic.ts +173 -14
- package/src/components/applications/ApplicationsView.tsx +3 -0
- package/src/components/capacity/CapacityPoolDetail.tsx +18 -7
- package/src/components/capacity/CapacityView.test.tsx +31 -1
- package/src/components/capacity/shared.tsx +12 -21
- package/src/components/cnpg/CNPGProtection.tsx +2 -2
- package/src/components/diagnose/AgentSetupNotice.test.tsx +58 -0
- package/src/components/diagnose/AgentSetupNotice.tsx +142 -51
- package/src/components/diagnose/ApplyDialog.ack.test.tsx +47 -0
- package/src/components/diagnose/ApplyDialog.test.tsx +59 -6
- package/src/components/diagnose/ApplyDialog.tsx +26 -28
- package/src/components/diagnose/DiagnoseContext.test.ts +43 -0
- package/src/components/diagnose/DiagnoseContext.tsx +136 -54
- package/src/components/diagnose/DiagnoseSurface.tsx +16 -3
- package/src/components/diagnose/InvestigationView.tsx +39 -2
- package/src/components/diagnose/LocalDiagnoseAction.tsx +40 -20
- package/src/components/diagnose/agentCatalog.ts +2 -2
- package/src/components/diagnose/diagnoseEvidenceTypes.ts +2 -0
- package/src/components/dock/TrafficFlowListTab.tsx +27 -6
- package/src/components/execution/BatchExecutionView.tsx +11 -11
- package/src/components/helm/ChartBrowser.tsx +3 -3
- package/src/components/helm/RevisionHistory.tsx +3 -2
- package/src/components/home/ClusterHealthCard.tsx +7 -0
- package/src/components/home/mcpToolCatalog.ts +1 -1
- package/src/components/resources/renderers/KyvernoPolicyQueued.tsx +2 -1
- package/src/components/resources/renderers/NodeRenderer.tsx +7 -1
- package/src/components/resources/renderers/ServiceRenderer.test.tsx +81 -0
- package/src/components/resources/renderers/ServiceRenderer.tsx +19 -14
- package/src/components/settings/SettingsDialog.tsx +122 -19
- package/src/components/shared/LargeClusterNamespacePicker.tsx +11 -5
- package/src/components/timeline/TimelineList.tsx +3 -0
- package/src/components/timeline/TimelineRetentionNote.test.tsx +49 -0
- package/src/components/timeline/TimelineRetentionNote.tsx +20 -0
- package/src/components/timeline/TimelineView.tsx +7 -0
- package/src/components/traffic/TrafficConnectionsTable.tsx +144 -0
- package/src/components/traffic/TrafficFilterSidebar.tsx +22 -1
- package/src/components/traffic/TrafficFlowListContext.tsx +12 -4
- package/src/components/traffic/TrafficFocusSearch.tsx +172 -0
- package/src/components/traffic/TrafficGraph.tsx +109 -26
- package/src/components/traffic/TrafficGraphTooLarge.tsx +150 -0
- package/src/components/traffic/TrafficView.tsx +582 -149
- package/src/components/traffic/trafficFilters.test.ts +398 -1
- package/src/components/traffic/trafficFilters.ts +408 -0
- package/src/components/workload/WorkloadView.test.ts +2 -1
- package/src/components/workload/WorkloadView.tsx +135 -290
- package/src/context/TimelineCustomization.tsx +15 -0
- package/src/hooks/useGitOpsWriteGuard.test.tsx +457 -0
- package/src/hooks/useGitOpsWriteGuard.ts +193 -0
- package/src/hooks/useResolvedGitOpsOwner.ts +256 -0
package/src/api/traffic.ts
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
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
|
-
|
|
59
|
+
const queryClient = useQueryClient()
|
|
60
|
+
const query = useQuery<TrafficSourcesResponse>({
|
|
32
61
|
queryKey: ['traffic-sources'],
|
|
33
|
-
queryFn: () =>
|
|
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
|
-
//
|
|
40
|
-
|
|
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
|
-
|
|
96
|
+
excludeNamespaces?: readonly string[]
|
|
97
|
+
excludeHost?: boolean
|
|
44
98
|
}
|
|
45
99
|
|
|
46
|
-
|
|
47
|
-
const { namespaces = [], since, enabled = true } = options
|
|
48
|
-
|
|
100
|
+
function trafficScopeParams({ namespaces = [], since, excludeNamespaces, excludeHost }: TrafficScope): URLSearchParams {
|
|
49
101
|
const params = new URLSearchParams()
|
|
50
|
-
//
|
|
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',
|
|
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
|
-
|
|
680
|
-
<
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
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 (
|
|
611
|
-
return (
|
|
612
|
-
|
|
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
|
-
<
|
|
630
|
-
|
|
631
|
-
|
|
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
|
|
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'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
|
+
});
|