@skyhook-io/k8s-ui 1.7.6 → 1.7.8
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 +6 -1
- package/src/components/charts/PrometheusChartsView.tsx +233 -0
- package/src/components/charts/index.ts +13 -0
- package/src/components/checks/ChecksView.tsx +5 -1
- package/src/components/gitops/GitOpsDetailLayout.tsx +4 -4
- package/src/components/gitops/GitOpsTableView.tsx +203 -6
- package/src/components/gitops/index.ts +1 -0
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +3 -3
- package/src/components/issues/IssuesView.tsx +352 -0
- package/src/components/issues/index.ts +24 -0
- package/src/components/issues/issues.test.ts +100 -0
- package/src/components/issues/severity.ts +125 -0
- package/src/components/issues/types.ts +194 -0
- package/src/components/resources/ResourcesView.tsx +27 -25
- package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +47 -3
- package/src/components/resources/renderers/CAPIKubeadmControlPlaneRenderer.tsx +1 -1
- package/src/components/resources/renderers/CAPIMachineDeploymentRenderer.tsx +1 -1
- package/src/components/resources/renderers/CAPIMachineSetRenderer.tsx +1 -1
- package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +1 -1
- package/src/components/resources/renderers/CompositionRenderer.tsx +3 -3
- package/src/components/resources/renderers/CrossplanePackageRenderer.tsx +5 -5
- package/src/components/resources/renderers/CrossplaneProviderConfigRenderer.tsx +1 -1
- package/src/components/resources/renderers/KnativeSourceRenderer.tsx +1 -1
- package/src/components/resources/renderers/PodRenderer.test.tsx +53 -0
- package/src/components/resources/renderers/PodRenderer.tsx +7 -1
- package/src/components/resources/renderers/VeleroBackupRenderer.tsx +1 -1
- package/src/components/resources/renderers/WorkloadRenderer.test.tsx +41 -0
- package/src/components/resources/renderers/WorkloadRenderer.tsx +49 -7
- package/src/components/resources/renderers/XRDRenderer.tsx +2 -2
- package/src/components/resources/resource-utils-argo.ts +18 -0
- package/src/components/shared/DetailShell.tsx +107 -0
- package/src/components/shared/ManagedByChip.tsx +149 -16
- package/src/components/shared/ResourceRendererDispatch.test.tsx +45 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +13 -1
- package/src/components/shared/index.ts +2 -1
- package/src/components/timeline/TimelineSwimlanes.tsx +1320 -0
- package/src/components/timeline/index.ts +1 -0
- package/src/components/topology/K8sResourceNode.tsx +60 -60
- package/src/components/topology/TopologyFilterSidebar.tsx +25 -3
- package/src/components/topology/TopologyGraph.tsx +168 -52
- package/src/components/topology/TopologySearch.tsx +17 -8
- package/src/components/topology/topology-search-match.test.ts +1 -1
- package/src/components/topology/topology.css +18 -0
- package/src/components/ui/RowActionMenu.tsx +149 -0
- package/src/components/ui/Tooltip.tsx +37 -2
- package/src/components/ui/index.ts +2 -0
- package/src/components/workload/WorkloadView.tsx +118 -112
- package/src/index.ts +5 -0
- package/src/theme/components.css +16 -0
- package/src/types/core.ts +3 -2
- package/src/utils/env-from.ts +3 -0
- package/src/utils/git-provider-urls.test.ts +348 -0
- package/src/utils/git-provider-urls.ts +142 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/replica-scalers.ts +9 -0
- package/src/components/resources/resources-search-sidebar-hint.test.ts +0 -85
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { buildRepoBrowseUrl, buildPathBrowseUrl } from './git-provider-urls'
|
|
3
|
+
|
|
4
|
+
describe('buildRepoBrowseUrl', () => {
|
|
5
|
+
// Pass-through semantics: link the user's URL exactly as configured, no rewriting.
|
|
6
|
+
it('returns the input verbatim for https URL', () => {
|
|
7
|
+
expect(buildRepoBrowseUrl('https://github.com/KoalaOps/deployment')).toBe(
|
|
8
|
+
'https://github.com/KoalaOps/deployment'
|
|
9
|
+
)
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('preserves trailing .git suffix (no manipulation)', () => {
|
|
13
|
+
expect(buildRepoBrowseUrl('https://github.com/KoalaOps/deployment.git')).toBe(
|
|
14
|
+
'https://github.com/KoalaOps/deployment.git'
|
|
15
|
+
)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('preserves trailing slash (no manipulation)', () => {
|
|
19
|
+
expect(buildRepoBrowseUrl('https://github.com/KoalaOps/deployment/')).toBe(
|
|
20
|
+
'https://github.com/KoalaOps/deployment/'
|
|
21
|
+
)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('trims surrounding whitespace', () => {
|
|
25
|
+
expect(buildRepoBrowseUrl(' https://github.com/o/r ')).toBe(
|
|
26
|
+
'https://github.com/o/r'
|
|
27
|
+
)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('keeps unknown hosts (still a valid http(s) link)', () => {
|
|
31
|
+
expect(buildRepoBrowseUrl('https://git.internal.example.com/team/proj')).toBe(
|
|
32
|
+
'https://git.internal.example.com/team/proj'
|
|
33
|
+
)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('preserves non-default port in self-hosted URLs', () => {
|
|
37
|
+
expect(buildRepoBrowseUrl('https://gitea.internal:3000/team/repo')).toBe(
|
|
38
|
+
'https://gitea.internal:3000/team/repo'
|
|
39
|
+
)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('preserves http:// scheme (does not silently upgrade to https)', () => {
|
|
43
|
+
expect(buildRepoBrowseUrl('http://corp.example.com/team/repo')).toBe(
|
|
44
|
+
'http://corp.example.com/team/repo'
|
|
45
|
+
)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('returns null for SCP-form (git@host:owner/repo) — not http(s)', () => {
|
|
49
|
+
expect(buildRepoBrowseUrl('git@github.com:KoalaOps/deployment.git')).toBe(null)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('returns null for ssh:// scheme', () => {
|
|
53
|
+
expect(buildRepoBrowseUrl('ssh://git@github.com/KoalaOps/deployment.git')).toBe(null)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('returns null for git+ssh:// scheme', () => {
|
|
57
|
+
expect(buildRepoBrowseUrl('git+ssh://git@github.com/o/r.git')).toBe(null)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('returns null for oci:// scheme (Helm OCI registry)', () => {
|
|
61
|
+
expect(buildRepoBrowseUrl('oci://registry.example.com/charts/nginx')).toBe(null)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('returns null for file:// scheme', () => {
|
|
65
|
+
expect(buildRepoBrowseUrl('file:///tmp/repo')).toBe(null)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('returns null for javascript: scheme (XSS-safe)', () => {
|
|
69
|
+
expect(buildRepoBrowseUrl('javascript:alert(1)')).toBe(null)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('returns null for empty / nullish input', () => {
|
|
73
|
+
expect(buildRepoBrowseUrl('')).toBe(null)
|
|
74
|
+
expect(buildRepoBrowseUrl(undefined)).toBe(null)
|
|
75
|
+
expect(buildRepoBrowseUrl(null)).toBe(null)
|
|
76
|
+
expect(buildRepoBrowseUrl(' ')).toBe(null)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('returns null for non-URL garbage', () => {
|
|
80
|
+
expect(buildRepoBrowseUrl('not a url')).toBe(null)
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
describe('buildPathBrowseUrl - GitHub', () => {
|
|
85
|
+
it('builds tree URL with branch ref', () => {
|
|
86
|
+
expect(
|
|
87
|
+
buildPathBrowseUrl(
|
|
88
|
+
'https://github.com/KoalaOps/deployment',
|
|
89
|
+
'argocd/addons/keda/keda/nonprod-cluster-us-east1',
|
|
90
|
+
'main'
|
|
91
|
+
)
|
|
92
|
+
).toBe(
|
|
93
|
+
'https://github.com/KoalaOps/deployment/tree/main/argocd/addons/keda/keda/nonprod-cluster-us-east1'
|
|
94
|
+
)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('uses HEAD when targetRevision is empty', () => {
|
|
98
|
+
expect(
|
|
99
|
+
buildPathBrowseUrl('https://github.com/o/r', 'src', '')
|
|
100
|
+
).toBe('https://github.com/o/r/tree/HEAD/src')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('uses HEAD when targetRevision is literally "HEAD"', () => {
|
|
104
|
+
expect(
|
|
105
|
+
buildPathBrowseUrl('https://github.com/o/r', 'src', 'HEAD')
|
|
106
|
+
).toBe('https://github.com/o/r/tree/HEAD/src')
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('passes SHA through as the ref (no special prefix)', () => {
|
|
110
|
+
const sha = 'a'.repeat(40)
|
|
111
|
+
expect(
|
|
112
|
+
buildPathBrowseUrl('https://github.com/o/r', 'src', sha)
|
|
113
|
+
).toBe(`https://github.com/o/r/tree/${sha}/src`)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('works with .git suffix on repo URL', () => {
|
|
117
|
+
expect(
|
|
118
|
+
buildPathBrowseUrl('https://github.com/o/r.git', 'a/b', 'main')
|
|
119
|
+
).toBe('https://github.com/o/r/tree/main/a/b')
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('url-encodes path segments with spaces', () => {
|
|
123
|
+
expect(
|
|
124
|
+
buildPathBrowseUrl('https://github.com/o/r', 'dir with space/file', 'main')
|
|
125
|
+
).toBe('https://github.com/o/r/tree/main/dir%20with%20space/file')
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('drops empty path segments from double slashes', () => {
|
|
129
|
+
expect(
|
|
130
|
+
buildPathBrowseUrl('https://github.com/o/r', 'a//b', 'main')
|
|
131
|
+
).toBe('https://github.com/o/r/tree/main/a/b')
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('preserves slashes in branch names (feature/foo)', () => {
|
|
135
|
+
expect(
|
|
136
|
+
buildPathBrowseUrl('https://github.com/o/r', 'src', 'feature/foo')
|
|
137
|
+
).toBe('https://github.com/o/r/tree/feature/foo/src')
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('treats uppercase 40-hex as a SHA (no special prefix)', () => {
|
|
141
|
+
const sha = 'A'.repeat(40)
|
|
142
|
+
expect(
|
|
143
|
+
buildPathBrowseUrl('https://github.com/o/r', 'src', sha)
|
|
144
|
+
).toBe(`https://github.com/o/r/tree/${sha}/src`)
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
describe('buildPathBrowseUrl - GitLab', () => {
|
|
149
|
+
it('builds /-/tree URL', () => {
|
|
150
|
+
expect(
|
|
151
|
+
buildPathBrowseUrl('https://gitlab.com/group/proj', 'src/app', 'main')
|
|
152
|
+
).toBe('https://gitlab.com/group/proj/-/tree/main/src/app')
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('supports nested subgroups (full group path before /-/tree)', () => {
|
|
156
|
+
expect(
|
|
157
|
+
buildPathBrowseUrl('https://gitlab.com/group/sub/proj', 'src', 'main')
|
|
158
|
+
).toBe('https://gitlab.com/group/sub/proj/-/tree/main/src')
|
|
159
|
+
})
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
describe('buildPathBrowseUrl - Bitbucket', () => {
|
|
163
|
+
it('builds /src URL with explicit ref', () => {
|
|
164
|
+
expect(
|
|
165
|
+
buildPathBrowseUrl('https://bitbucket.org/team/proj', 'src/app', 'develop')
|
|
166
|
+
).toBe('https://bitbucket.org/team/proj/src/develop/src/app')
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
// Bitbucket Cloud /src/ doesn't accept "HEAD" — better no link than a 404 link.
|
|
170
|
+
it('returns null when ref is empty (HEAD not a valid Bitbucket ref token)', () => {
|
|
171
|
+
expect(
|
|
172
|
+
buildPathBrowseUrl('https://bitbucket.org/team/proj', 'src/app', '')
|
|
173
|
+
).toBe(null)
|
|
174
|
+
})
|
|
175
|
+
it('returns null when ref is literally "HEAD"', () => {
|
|
176
|
+
expect(
|
|
177
|
+
buildPathBrowseUrl('https://bitbucket.org/team/proj', 'src/app', 'HEAD')
|
|
178
|
+
).toBe(null)
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
describe('buildPathBrowseUrl - Azure DevOps', () => {
|
|
183
|
+
it('builds dev.azure.com URL with GB prefix for branches', () => {
|
|
184
|
+
expect(
|
|
185
|
+
buildPathBrowseUrl(
|
|
186
|
+
'https://dev.azure.com/myorg/MyProject/_git/myrepo',
|
|
187
|
+
'src/app',
|
|
188
|
+
'main'
|
|
189
|
+
)
|
|
190
|
+
).toBe(
|
|
191
|
+
'https://dev.azure.com/myorg/MyProject/_git/myrepo?path=/src/app&version=GBmain'
|
|
192
|
+
)
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('uses GC prefix for SHA refs (lowercase)', () => {
|
|
196
|
+
const sha = 'a'.repeat(40)
|
|
197
|
+
expect(
|
|
198
|
+
buildPathBrowseUrl(
|
|
199
|
+
'https://dev.azure.com/myorg/MyProject/_git/myrepo',
|
|
200
|
+
'src',
|
|
201
|
+
sha
|
|
202
|
+
)
|
|
203
|
+
).toBe(
|
|
204
|
+
`https://dev.azure.com/myorg/MyProject/_git/myrepo?path=/src&version=GC${sha}`
|
|
205
|
+
)
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('uses GC prefix for SHA refs (uppercase)', () => {
|
|
209
|
+
const sha = 'A'.repeat(40)
|
|
210
|
+
expect(
|
|
211
|
+
buildPathBrowseUrl(
|
|
212
|
+
'https://dev.azure.com/myorg/MyProject/_git/myrepo',
|
|
213
|
+
'src',
|
|
214
|
+
sha
|
|
215
|
+
)
|
|
216
|
+
).toBe(
|
|
217
|
+
`https://dev.azure.com/myorg/MyProject/_git/myrepo?path=/src&version=GC${sha}`
|
|
218
|
+
)
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it('percent-encodes slashes in branch names (query-string context)', () => {
|
|
222
|
+
expect(
|
|
223
|
+
buildPathBrowseUrl(
|
|
224
|
+
'https://dev.azure.com/myorg/MyProject/_git/myrepo',
|
|
225
|
+
'src',
|
|
226
|
+
'feature/foo'
|
|
227
|
+
)
|
|
228
|
+
).toBe(
|
|
229
|
+
'https://dev.azure.com/myorg/MyProject/_git/myrepo?path=/src&version=GBfeature%2Ffoo'
|
|
230
|
+
)
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('omits version when ref is HEAD/empty', () => {
|
|
234
|
+
expect(
|
|
235
|
+
buildPathBrowseUrl(
|
|
236
|
+
'https://dev.azure.com/myorg/MyProject/_git/myrepo',
|
|
237
|
+
'src',
|
|
238
|
+
'HEAD'
|
|
239
|
+
)
|
|
240
|
+
).toBe('https://dev.azure.com/myorg/MyProject/_git/myrepo?path=/src')
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('supports legacy visualstudio.com host', () => {
|
|
244
|
+
expect(
|
|
245
|
+
buildPathBrowseUrl(
|
|
246
|
+
'https://myorg.visualstudio.com/MyProject/_git/myrepo',
|
|
247
|
+
'src',
|
|
248
|
+
'main'
|
|
249
|
+
)
|
|
250
|
+
).toBe(
|
|
251
|
+
'https://dev.azure.com/myorg/MyProject/_git/myrepo?path=/src&version=GBmain'
|
|
252
|
+
)
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it('does not double-encode project/repo segments that arrived percent-encoded', () => {
|
|
256
|
+
expect(
|
|
257
|
+
buildPathBrowseUrl(
|
|
258
|
+
'https://dev.azure.com/myorg/My%20Project/_git/My%20Repo',
|
|
259
|
+
'src',
|
|
260
|
+
'main'
|
|
261
|
+
)
|
|
262
|
+
).toBe(
|
|
263
|
+
'https://dev.azure.com/myorg/My%20Project/_git/My%20Repo?path=/src&version=GBmain'
|
|
264
|
+
)
|
|
265
|
+
})
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
describe('buildPathBrowseUrl - unknown / no path', () => {
|
|
269
|
+
it('returns null for unknown hosts', () => {
|
|
270
|
+
expect(
|
|
271
|
+
buildPathBrowseUrl('https://git.internal.example.com/team/proj', 'src', 'main')
|
|
272
|
+
).toBe(null)
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
it('returns null when path is empty', () => {
|
|
276
|
+
expect(buildPathBrowseUrl('https://github.com/o/r', '', 'main')).toBe(null)
|
|
277
|
+
expect(buildPathBrowseUrl('https://github.com/o/r', ' ', 'main')).toBe(null)
|
|
278
|
+
expect(buildPathBrowseUrl('https://github.com/o/r', null, 'main')).toBe(null)
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
it('returns null when path has only slashes', () => {
|
|
282
|
+
expect(buildPathBrowseUrl('https://github.com/o/r', '///', 'main')).toBe(null)
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
it('returns null for nullish repo URL', () => {
|
|
286
|
+
expect(buildPathBrowseUrl(undefined, 'src', 'main')).toBe(null)
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
// Known host but path too short to identify owner+repo -> downgrade to unknown.
|
|
290
|
+
// Pins the `pathParts.length < 2` guard across all three slash-providers.
|
|
291
|
+
it('returns null when github URL has only owner segment', () => {
|
|
292
|
+
expect(buildPathBrowseUrl('https://github.com/onlyowner', 'src', 'main')).toBe(null)
|
|
293
|
+
})
|
|
294
|
+
it('returns null when bitbucket URL has only owner segment', () => {
|
|
295
|
+
expect(buildPathBrowseUrl('https://bitbucket.org/onlyowner', 'src', 'main')).toBe(null)
|
|
296
|
+
})
|
|
297
|
+
it('returns null when gitlab URL has only one segment', () => {
|
|
298
|
+
expect(buildPathBrowseUrl('https://gitlab.com/onlyone', 'src', 'main')).toBe(null)
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
// Azure DevOps `_git` index arithmetic — high-risk branch.
|
|
302
|
+
it('returns null when dev.azure.com URL is missing _git', () => {
|
|
303
|
+
expect(
|
|
304
|
+
buildPathBrowseUrl('https://dev.azure.com/myorg/MyProject/myrepo', 'src', 'main')
|
|
305
|
+
).toBe(null)
|
|
306
|
+
})
|
|
307
|
+
it('returns null when dev.azure.com URL ends with _git (no repo segment)', () => {
|
|
308
|
+
expect(
|
|
309
|
+
buildPathBrowseUrl('https://dev.azure.com/myorg/MyProject/_git', 'src', 'main')
|
|
310
|
+
).toBe(null)
|
|
311
|
+
})
|
|
312
|
+
it('returns null when dev.azure.com URL has _git but no project (gitIdx < 2)', () => {
|
|
313
|
+
expect(
|
|
314
|
+
buildPathBrowseUrl('https://dev.azure.com/myorg/_git/myrepo', 'src', 'main')
|
|
315
|
+
).toBe(null)
|
|
316
|
+
})
|
|
317
|
+
it('returns null when visualstudio.com URL is missing _git', () => {
|
|
318
|
+
expect(
|
|
319
|
+
buildPathBrowseUrl('https://myorg.visualstudio.com/MyProject/myrepo', 'src', 'main')
|
|
320
|
+
).toBe(null)
|
|
321
|
+
})
|
|
322
|
+
it('returns null when visualstudio.com URL ends with _git', () => {
|
|
323
|
+
expect(
|
|
324
|
+
buildPathBrowseUrl('https://myorg.visualstudio.com/MyProject/_git', 'src', 'main')
|
|
325
|
+
).toBe(null)
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
// Pin .toUpperCase() behavior so a refactor to === 'HEAD' would fail loudly.
|
|
329
|
+
it('treats lowercase "head" the same as "HEAD" (falls back to default branch)', () => {
|
|
330
|
+
expect(
|
|
331
|
+
buildPathBrowseUrl('https://github.com/o/r', 'src', 'head')
|
|
332
|
+
).toBe('https://github.com/o/r/tree/HEAD/src')
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
// Pin SHA_RE's exact-40-char requirement. Loosening to {7,40} would mis-prefix
|
|
336
|
+
// short-hex branch names like "abc1234" as Azure commits (GC) instead of branches (GB).
|
|
337
|
+
it('does not treat 7-char hex as a SHA on Azure (stays GB)', () => {
|
|
338
|
+
expect(
|
|
339
|
+
buildPathBrowseUrl(
|
|
340
|
+
'https://dev.azure.com/myorg/MyProject/_git/myrepo',
|
|
341
|
+
'src',
|
|
342
|
+
'abc1234'
|
|
343
|
+
)
|
|
344
|
+
).toBe(
|
|
345
|
+
'https://dev.azure.com/myorg/MyProject/_git/myrepo?path=/src&version=GBabc1234'
|
|
346
|
+
)
|
|
347
|
+
})
|
|
348
|
+
})
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
type ParsedRepo =
|
|
2
|
+
| { provider: 'github' | 'gitlab' | 'bitbucket'; owner: string; repo: string }
|
|
3
|
+
| { provider: 'azure-devops'; org: string; project: string; repo: string }
|
|
4
|
+
| { provider: 'unknown' }
|
|
5
|
+
|
|
6
|
+
const SHA_RE = /^[0-9a-f]{40}$/i
|
|
7
|
+
|
|
8
|
+
function stripDotGit(s: string): string {
|
|
9
|
+
return s.replace(/\.git$/i, '')
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function encodePath(path: string): string {
|
|
13
|
+
return path
|
|
14
|
+
.split('/')
|
|
15
|
+
.filter(seg => seg.length > 0)
|
|
16
|
+
.map(encodeURIComponent)
|
|
17
|
+
.join('/')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Refs (branches) can contain slashes — feature/foo — which providers serve as
|
|
21
|
+
// literal path segments. encodeURIComponent would turn those into %2F and 404.
|
|
22
|
+
function encodeRef(ref: string): string {
|
|
23
|
+
return ref.split('/').map(encodeURIComponent).join('/')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Validate `repoURL` is a well-formed http(s) URL and return the URL object.
|
|
27
|
+
// SCP-form (git@host:o/r), ssh://, git+ssh://, oci://, file://, etc. all
|
|
28
|
+
// return null — we don't manipulate user-supplied URLs to make them linkable.
|
|
29
|
+
function parseHttpRepoUrl(repoURL: string | undefined | null): URL | null {
|
|
30
|
+
if (!repoURL) return null
|
|
31
|
+
const trimmed = repoURL.trim()
|
|
32
|
+
if (!trimmed) return null
|
|
33
|
+
let url: URL
|
|
34
|
+
try {
|
|
35
|
+
url = new URL(trimmed)
|
|
36
|
+
} catch {
|
|
37
|
+
return null
|
|
38
|
+
}
|
|
39
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null
|
|
40
|
+
return url
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function detectProvider(url: URL): ParsedRepo {
|
|
44
|
+
const hostname = url.hostname.toLowerCase()
|
|
45
|
+
const pathParts = url.pathname.replace(/^\/+/, '').replace(/\/+$/, '').split('/')
|
|
46
|
+
|
|
47
|
+
if (hostname === 'github.com' || hostname === 'bitbucket.org') {
|
|
48
|
+
if (pathParts.length < 2) return { provider: 'unknown' }
|
|
49
|
+
return {
|
|
50
|
+
provider: hostname === 'github.com' ? 'github' : 'bitbucket',
|
|
51
|
+
owner: pathParts[0],
|
|
52
|
+
repo: stripDotGit(pathParts[1]),
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (hostname === 'gitlab.com') {
|
|
57
|
+
// GitLab supports nested groups: the owner segment may itself be a slash-joined path.
|
|
58
|
+
if (pathParts.length < 2) return { provider: 'unknown' }
|
|
59
|
+
return {
|
|
60
|
+
provider: 'gitlab',
|
|
61
|
+
owner: pathParts.slice(0, -1).join('/'),
|
|
62
|
+
repo: stripDotGit(pathParts[pathParts.length - 1]),
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Azure DevOps URL shape: /{org}/{project}/_git/{repo} (dev.azure.com)
|
|
67
|
+
// or {org}.visualstudio.com/{project}/_git/{repo} (legacy).
|
|
68
|
+
if (hostname === 'dev.azure.com') {
|
|
69
|
+
const gitIdx = pathParts.indexOf('_git')
|
|
70
|
+
if (gitIdx < 2 || gitIdx + 1 >= pathParts.length) {
|
|
71
|
+
return { provider: 'unknown' }
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
provider: 'azure-devops',
|
|
75
|
+
org: pathParts.slice(0, gitIdx - 1).join('/'),
|
|
76
|
+
project: pathParts[gitIdx - 1],
|
|
77
|
+
repo: pathParts[gitIdx + 1],
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (hostname.endsWith('.visualstudio.com')) {
|
|
82
|
+
const gitIdx = pathParts.indexOf('_git')
|
|
83
|
+
if (gitIdx < 1 || gitIdx + 1 >= pathParts.length) {
|
|
84
|
+
return { provider: 'unknown' }
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
provider: 'azure-devops',
|
|
88
|
+
org: hostname.slice(0, -'.visualstudio.com'.length),
|
|
89
|
+
project: pathParts.slice(0, gitIdx).join('/'),
|
|
90
|
+
repo: pathParts[gitIdx + 1],
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return { provider: 'unknown' }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Pass-through linkability check: link the user's repoURL as-is iff it parses
|
|
98
|
+
// as http(s); never rewrite SCP/SSH forms.
|
|
99
|
+
export function buildRepoBrowseUrl(repoURL: string | undefined | null): string | null {
|
|
100
|
+
return parseHttpRepoUrl(repoURL) ? repoURL!.trim() : null
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function buildPathBrowseUrl(
|
|
104
|
+
repoURL: string | undefined | null,
|
|
105
|
+
path: string | undefined | null,
|
|
106
|
+
targetRevision: string | undefined | null
|
|
107
|
+
): string | null {
|
|
108
|
+
if (!path || !path.trim()) return null
|
|
109
|
+
const url = parseHttpRepoUrl(repoURL)
|
|
110
|
+
if (!url) return null
|
|
111
|
+
const parsed = detectProvider(url)
|
|
112
|
+
if (parsed.provider === 'unknown') return null
|
|
113
|
+
|
|
114
|
+
const rawRef = (targetRevision ?? '').trim()
|
|
115
|
+
// GitHub and GitLab browse URLs accept "HEAD" as a ref token that resolves
|
|
116
|
+
// to the default branch. Bitbucket Cloud's /src/ path does not — see the
|
|
117
|
+
// bitbucket case below.
|
|
118
|
+
const hasExplicitRef = rawRef !== '' && rawRef.toUpperCase() !== 'HEAD'
|
|
119
|
+
const ref = hasExplicitRef ? rawRef : 'HEAD'
|
|
120
|
+
const encodedPath = encodePath(path)
|
|
121
|
+
if (!encodedPath) return null
|
|
122
|
+
|
|
123
|
+
switch (parsed.provider) {
|
|
124
|
+
case 'github':
|
|
125
|
+
return `https://github.com/${parsed.owner}/${parsed.repo}/tree/${encodeRef(ref)}/${encodedPath}`
|
|
126
|
+
case 'gitlab':
|
|
127
|
+
return `https://gitlab.com/${parsed.owner}/${parsed.repo}/-/tree/${encodeRef(ref)}/${encodedPath}`
|
|
128
|
+
case 'bitbucket':
|
|
129
|
+
// Bitbucket Cloud's /src/{ref}/... endpoint requires a real branch name
|
|
130
|
+
// or commit hash; HEAD 404s. Without an explicit ref we can't build a
|
|
131
|
+
// working deep link, so fall through to plain text.
|
|
132
|
+
if (!hasExplicitRef) return null
|
|
133
|
+
return `https://bitbucket.org/${parsed.owner}/${parsed.repo}/src/${encodeRef(ref)}/${encodedPath}`
|
|
134
|
+
case 'azure-devops': {
|
|
135
|
+
const isSha = hasExplicitRef && SHA_RE.test(rawRef)
|
|
136
|
+
const versionParam = hasExplicitRef ? `&version=${isSha ? 'GC' : 'GB'}${encodeURIComponent(rawRef)}` : ''
|
|
137
|
+
// org/project/repo are taken straight from url.pathname segments, which the URL
|
|
138
|
+
// parser leaves percent-encoded — re-encoding would double-encode (My%20X → My%2520X).
|
|
139
|
+
return `https://dev.azure.com/${parsed.org}/${parsed.project}/_git/${parsed.repo}?path=/${encodedPath}${versionParam}`
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
package/src/utils/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ export * from './animation'
|
|
|
8
8
|
export * from './resource-hierarchy'
|
|
9
9
|
export * from './log-format'
|
|
10
10
|
export * from './download'
|
|
11
|
+
export * from './env-from'
|
|
11
12
|
export * from './api-resources'
|
|
12
13
|
export * from './skeleton-yaml'
|
|
13
14
|
export * from './k8s-errors'
|
|
@@ -17,3 +18,4 @@ export * from './validators'
|
|
|
17
18
|
export * from './gitops-owner'
|
|
18
19
|
export * from './gitops-route'
|
|
19
20
|
export * from './rbac-badges'
|
|
21
|
+
export * from './git-provider-urls'
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ResourceRef } from '../types'
|
|
2
|
+
|
|
3
|
+
export function replicaScalers(scalers?: ResourceRef[]): ResourceRef[] | undefined {
|
|
4
|
+
const result = scalers?.filter((ref) => {
|
|
5
|
+
const kind = ref.kind.toLowerCase()
|
|
6
|
+
return kind === 'horizontalpodautoscaler' || kind === 'scaledobject'
|
|
7
|
+
})
|
|
8
|
+
return result && result.length > 0 ? result : undefined
|
|
9
|
+
}
|
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import { pluralize } from '../../utils/pluralize'
|
|
3
|
-
import type { SelectedKindInfo } from './ResourcesSidebar'
|
|
4
|
-
|
|
5
|
-
// Mirrors the empty-state hint rule inside ResourcesView (kept in
|
|
6
|
-
// sync by reuse) — pure so we can pin the lookup + render contract
|
|
7
|
-
// without rendering the React component. See
|
|
8
|
-
// `packages/k8s-ui/src/components/resources/ResourcesView.tsx`
|
|
9
|
-
// search for "the count is unfiltered".
|
|
10
|
-
function sidebarHint(
|
|
11
|
-
counts: Record<string, number>,
|
|
12
|
-
selectedKind: SelectedKindInfo,
|
|
13
|
-
searchTerm: string,
|
|
14
|
-
): string | null {
|
|
15
|
-
if (!searchTerm) return null
|
|
16
|
-
const key = selectedKind.group ? `${selectedKind.group}/${selectedKind.kind}` : selectedKind.kind
|
|
17
|
-
const totalForKind = counts[key] ?? 0
|
|
18
|
-
if (totalForKind === 0) return null
|
|
19
|
-
return `The sidebar shows ${pluralize(totalForKind, selectedKind.kind)} in the cluster — the count is unfiltered.`
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
describe('ResourcesView empty-search sidebar hint (SKY-828 bug 46)', () => {
|
|
23
|
-
// The bug: sidebar count badge stays unfiltered and shows the
|
|
24
|
-
// cluster-wide total per kind. When a search returns zero rows
|
|
25
|
-
// (e.g. "5 Pods" in sidebar, "No Pods found" in pane) the user
|
|
26
|
-
// reads the badge as a lie. The empty-state appends a sentence
|
|
27
|
-
// making the unfiltered nature explicit. The rule must:
|
|
28
|
-
// 1. Suppress entirely when there's no active search.
|
|
29
|
-
// 2. Suppress when the cluster total for the kind is 0
|
|
30
|
-
// (no badge to explain → no hint).
|
|
31
|
-
// 3. Look up the count under `${group}/${kind}` for grouped
|
|
32
|
-
// kinds, plain `kind` for core kinds (matches sidebar key).
|
|
33
|
-
// 4. Pluralize via pluralize() so "Ingress" → "Ingresses",
|
|
34
|
-
// "NetworkPolicy" → "NetworkPolicies" (regression of the
|
|
35
|
-
// naive `${kind}s` Cursor Bugbot caught in 80eb64b).
|
|
36
|
-
// 5. Singular for n===1: "1 Pod", not "1 Pods".
|
|
37
|
-
|
|
38
|
-
const pod: SelectedKindInfo = { name: 'pods', kind: 'Pod', group: '' }
|
|
39
|
-
|
|
40
|
-
it('returns null when there is no active search', () => {
|
|
41
|
-
expect(sidebarHint({ Pod: 232 }, pod, '')).toBeNull()
|
|
42
|
-
})
|
|
43
|
-
|
|
44
|
-
it('returns null when the cluster total for the kind is 0', () => {
|
|
45
|
-
expect(sidebarHint({ Pod: 0 }, pod, 'xyz')).toBeNull()
|
|
46
|
-
})
|
|
47
|
-
|
|
48
|
-
it('returns null when the kind is missing from counts', () => {
|
|
49
|
-
expect(sidebarHint({}, pod, 'xyz')).toBeNull()
|
|
50
|
-
})
|
|
51
|
-
|
|
52
|
-
it('formats the sentence with the cluster total and pluralized kind', () => {
|
|
53
|
-
expect(sidebarHint({ Pod: 232 }, pod, 'xyz')).toBe(
|
|
54
|
-
'The sidebar shows 232 Pods in the cluster — the count is unfiltered.',
|
|
55
|
-
)
|
|
56
|
-
})
|
|
57
|
-
|
|
58
|
-
it('uses singular noun when total is 1', () => {
|
|
59
|
-
expect(sidebarHint({ Pod: 1 }, pod, 'xyz')).toBe(
|
|
60
|
-
'The sidebar shows 1 Pod in the cluster — the count is unfiltered.',
|
|
61
|
-
)
|
|
62
|
-
})
|
|
63
|
-
|
|
64
|
-
it('looks up grouped kinds under `${group}/${kind}`', () => {
|
|
65
|
-
const ar: SelectedKindInfo = { name: 'rollouts', kind: 'Rollout', group: 'argoproj.io' }
|
|
66
|
-
const counts = { Rollout: 999, 'argoproj.io/Rollout': 4 }
|
|
67
|
-
expect(sidebarHint(counts, ar, 'xyz')).toBe(
|
|
68
|
-
'The sidebar shows 4 Rollouts in the cluster — the count is unfiltered.',
|
|
69
|
-
)
|
|
70
|
-
})
|
|
71
|
-
|
|
72
|
-
it('pluralizes Ingress correctly (regression: not "Ingresss")', () => {
|
|
73
|
-
const ing: SelectedKindInfo = { name: 'ingresses', kind: 'Ingress', group: '' }
|
|
74
|
-
expect(sidebarHint({ Ingress: 3 }, ing, 'xyz')).toBe(
|
|
75
|
-
'The sidebar shows 3 Ingresses in the cluster — the count is unfiltered.',
|
|
76
|
-
)
|
|
77
|
-
})
|
|
78
|
-
|
|
79
|
-
it('pluralizes NetworkPolicy correctly (regression: not "NetworkPolicys")', () => {
|
|
80
|
-
const np: SelectedKindInfo = { name: 'networkpolicies', kind: 'NetworkPolicy', group: 'networking.k8s.io' }
|
|
81
|
-
expect(sidebarHint({ 'networking.k8s.io/NetworkPolicy': 2 }, np, 'xyz')).toBe(
|
|
82
|
-
'The sidebar shows 2 NetworkPolicies in the cluster — the count is unfiltered.',
|
|
83
|
-
)
|
|
84
|
-
})
|
|
85
|
-
})
|